From ad3f25a47d986b3aaba7610f1233202461c8972b Mon Sep 17 00:00:00 2001 From: Dave Gaeddert Date: Sat, 23 May 2026 13:10:18 -0500 Subject: [PATCH 01/21] Add typed where() with field-method conditions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of the typed query API. Field classes are now generic over their value type T; stubs return the parameterized descriptor instead of the primitive. Combined with Field's overloaded __get__, this gives: class User(postgres.Model): email = types.EmailField() note = types.TextField(allow_null=True, required=False) User.email # EmailField[str] — typed reference, .equals()/.contains()/... user.email # str — value type preserved User.note # TextField[str | None] user.note # str | None — nullability preserved Adds equals/not_equal/gt/gte/lt/lte/is_null on Field[T] and contains/icontains/startswith/endswith on TextField, each returning Q. New QuerySet.where(*conditions: Q) accepts them positionally with no **kwargs, so a type checker can reject field typos and value-type mismatches at the call site. Coexists with filter()/exclude(). Model field declarations across the monorepo drop their primitive annotation (`name: str = types.TextField()` → `name = types.TextField()`). The descriptor protocol handles both class- and instance-access typing. --- example/app/contacts/models.py | 16 +- example/app/notes/models.py | 12 +- example/app/tasks/models.py | 26 ++-- example/app/users/models.py | 8 +- plain-admin/plain/admin/models.py | 8 +- plain-admin/tests/app/users/models.py | 4 +- plain-api/plain/api/models.py | 19 +-- plain-auth/tests/app/users/models.py | 4 +- plain-cache/plain/cache/models.py | 13 +- plain-connect/tests/app/users/models.py | 2 +- plain-flags/plain/flags/models.py | 19 ++- plain-jobs/plain/jobs/models.py | 141 +++++++----------- plain-loginlink/tests/app/users/models.py | 2 +- plain-oauth/plain/oauth/models.py | 23 +-- plain-oauth/tests/app/users/models.py | 4 +- plain-observer/plain/observer/models.py | 42 +++--- plain-passwords/tests/app/users/models.py | 2 +- plain-postgres/plain/postgres/base.py | 2 +- plain-postgres/plain/postgres/fields/base.py | 38 ++++- .../plain/postgres/fields/binary.py | 4 +- .../plain/postgres/fields/boolean.py | 2 +- .../plain/postgres/fields/duration.py | 4 +- .../plain/postgres/fields/encrypted.py | 4 +- .../plain/postgres/fields/network.py | 2 +- .../plain/postgres/fields/numeric.py | 12 +- .../plain/postgres/fields/temporal.py | 12 +- plain-postgres/plain/postgres/fields/text.py | 21 ++- .../plain/postgres/fields/timezones.py | 4 +- plain-postgres/plain/postgres/fields/uuid.py | 2 +- plain-postgres/plain/postgres/query.py | 12 ++ plain-postgres/plain/postgres/types.pyi | 139 ++++++++++------- .../tests/app/examples/models/constraints.py | 4 +- .../tests/app/examples/models/defaults.py | 19 +-- .../tests/app/examples/models/delete.py | 14 +- .../tests/app/examples/models/encrypted.py | 8 +- .../tests/app/examples/models/forms.py | 28 ++-- .../tests/app/examples/models/indexes.py | 4 +- .../tests/app/examples/models/iteration.py | 4 +- .../tests/app/examples/models/mixins.py | 8 +- .../tests/app/examples/models/nullability.py | 2 +- .../tests/app/examples/models/querysets.py | 6 +- .../app/examples/models/relationships.py | 6 +- .../app/examples/models/storage_parameters.py | 2 +- .../tests/app/examples/models/trees.py | 2 +- .../test_autodetector_not_null_errors.py | 30 ++-- .../internal/test_autodetector_type_change.py | 12 +- .../tests/public/test_typed_where.py | 110 ++++++++++++++ plain-redirection/plain/redirection/models.py | 41 +++-- plain-sessions/plain/sessions/models.py | 10 +- 49 files changed, 531 insertions(+), 382 deletions(-) create mode 100644 plain-postgres/tests/public/test_typed_where.py diff --git a/example/app/contacts/models.py b/example/app/contacts/models.py index 2df440fa67..3bb2068bf1 100644 --- a/example/app/contacts/models.py +++ b/example/app/contacts/models.py @@ -1,7 +1,5 @@ from __future__ import annotations -import datetime - from plain import postgres from plain.postgres import types @@ -20,13 +18,13 @@ @postgres.register_model class ContactSubmission(postgres.Model): - name: str = types.TextField(max_length=100) - email: str = types.EmailField() - subject: str = types.TextField(max_length=20, choices=SUBJECT_CHOICES) - message: str = types.TextField() - company: str = types.TextField(max_length=200, default="", required=False) - subscribe: bool = types.BooleanField(default=False) - created_at: datetime.datetime = types.DateTimeField(create_now=True) + name = types.TextField(max_length=100) + email = types.EmailField() + subject = types.TextField(max_length=20, choices=SUBJECT_CHOICES) + message = types.TextField() + company = types.TextField(max_length=200, default="", required=False) + subscribe = types.BooleanField(default=False) + created_at = types.DateTimeField(create_now=True) query: postgres.QuerySet[ContactSubmission] = postgres.QuerySet() diff --git a/example/app/notes/models.py b/example/app/notes/models.py index e0d68212b1..30f3518c0b 100644 --- a/example/app/notes/models.py +++ b/example/app/notes/models.py @@ -1,7 +1,5 @@ from __future__ import annotations -import datetime - from app.users.models import User from plain import postgres from plain.postgres import types @@ -15,12 +13,10 @@ class Note(postgres.Model): on_delete=postgres.CASCADE, related_query_name="notes", ) - title: str = types.TextField(max_length=200) - body: str = types.TextField(default="", required=False) - created_at: datetime.datetime = types.DateTimeField(create_now=True) - updated_at: datetime.datetime = types.DateTimeField( - create_now=True, update_now=True - ) + title = types.TextField(max_length=200) + body = types.TextField(default="", required=False) + created_at = types.DateTimeField(create_now=True) + updated_at = types.DateTimeField(create_now=True, update_now=True) query: postgres.QuerySet[Note] = postgres.QuerySet() diff --git a/example/app/tasks/models.py b/example/app/tasks/models.py index 1ef664c304..845612077f 100644 --- a/example/app/tasks/models.py +++ b/example/app/tasks/models.py @@ -1,7 +1,5 @@ from __future__ import annotations -import datetime - from app.users.models import User from plain import postgres from plain.postgres import types @@ -22,8 +20,8 @@ class Project(postgres.Model): on_delete=postgres.CASCADE, related_query_name="projects", ) - name: str = types.TextField(max_length=100) - created_at: datetime.datetime = types.DateTimeField(create_now=True) + name = types.TextField(max_length=100) + created_at = types.DateTimeField(create_now=True) query: postgres.QuerySet[Project] = postgres.QuerySet() @@ -47,7 +45,7 @@ class Tag(postgres.Model): on_delete=postgres.CASCADE, related_query_name="tags", ) - name: str = types.TextField(max_length=40) + name = types.TextField(max_length=40) query: postgres.QuerySet[Tag] = postgres.QuerySet() @@ -98,18 +96,14 @@ class Task(postgres.Model): allow_null=True, required=False, ) - title: str = types.TextField(max_length=200) - notes: str = types.TextField(default="", required=False) - due_date: datetime.date | None = types.DateField(allow_null=True, required=False) - priority: str = types.TextField( - max_length=4, choices=PRIORITY_CHOICES, default="med" - ) - is_complete: bool = types.BooleanField(default=False) + title = types.TextField(max_length=200) + notes = types.TextField(default="", required=False) + due_date = types.DateField(allow_null=True, required=False) + priority = types.TextField(max_length=4, choices=PRIORITY_CHOICES, default="med") + is_complete = types.BooleanField(default=False) tags: types.ManyToManyManager[Tag] = types.ManyToManyField(Tag, through=TaskTag) - created_at: datetime.datetime = types.DateTimeField(create_now=True) - updated_at: datetime.datetime = types.DateTimeField( - create_now=True, update_now=True - ) + created_at = types.DateTimeField(create_now=True) + updated_at = types.DateTimeField(create_now=True, update_now=True) query: postgres.QuerySet[Task] = postgres.QuerySet() diff --git a/example/app/users/models.py b/example/app/users/models.py index 78e8521060..96e143d14b 100644 --- a/example/app/users/models.py +++ b/example/app/users/models.py @@ -1,7 +1,5 @@ from __future__ import annotations -import datetime - from plain import postgres from plain.passwords.types import PasswordField from plain.postgres import types @@ -9,9 +7,9 @@ @postgres.register_model class User(postgres.Model): - email: str = types.EmailField() + email = types.EmailField() password: str = PasswordField() - is_admin: bool = types.BooleanField(default=False) - created_at: datetime.datetime = types.DateTimeField(create_now=True) + is_admin = types.BooleanField(default=False) + created_at = types.DateTimeField(create_now=True) query: postgres.QuerySet[User] = postgres.QuerySet() diff --git a/plain-admin/plain/admin/models.py b/plain-admin/plain/admin/models.py index d7fad0ffd8..9d98a69488 100644 --- a/plain-admin/plain/admin/models.py +++ b/plain-admin/plain/admin/models.py @@ -1,7 +1,5 @@ from __future__ import annotations -from datetime import datetime - from plain import postgres from plain.postgres import types @@ -14,9 +12,9 @@ class PinnedNavItem(postgres.Model): "users.User", on_delete=postgres.CASCADE, ) - view_slug: str = types.TextField(max_length=255) - order: int = types.SmallIntegerField(default=0) - created_at: datetime = types.DateTimeField(create_now=True) + view_slug = types.TextField(max_length=255) + order = types.SmallIntegerField(default=0) + created_at = types.DateTimeField(create_now=True) query: postgres.QuerySet[PinnedNavItem] = postgres.QuerySet() diff --git a/plain-admin/tests/app/users/models.py b/plain-admin/tests/app/users/models.py index a407a98d78..60e0142eee 100644 --- a/plain-admin/tests/app/users/models.py +++ b/plain-admin/tests/app/users/models.py @@ -6,7 +6,7 @@ @postgres.register_model class User(postgres.Model): - username: str = types.TextField(max_length=255) - is_admin: bool = types.BooleanField(default=False) + username = types.TextField(max_length=255) + is_admin = types.BooleanField(default=False) query: postgres.QuerySet[User] = postgres.QuerySet() diff --git a/plain-api/plain/api/models.py b/plain-api/plain/api/models.py index 2be89a496f..8d9d320456 100644 --- a/plain-api/plain/api/models.py +++ b/plain-api/plain/api/models.py @@ -1,8 +1,5 @@ from __future__ import annotations -from datetime import datetime -from uuid import UUID - from plain import postgres from plain.postgres import types from plain.utils import timezone @@ -12,17 +9,17 @@ @postgres.register_model class APIKey(postgres.Model): - uuid: UUID = types.UUIDField(generate=True) - created_at: datetime = types.DateTimeField(create_now=True) - updated_at: datetime = types.DateTimeField(create_now=True, update_now=True) - expires_at: datetime | None = types.DateTimeField(required=False, allow_null=True) - last_used_at: datetime | None = types.DateTimeField(required=False, allow_null=True) + uuid = types.UUIDField(generate=True) + created_at = types.DateTimeField(create_now=True) + updated_at = types.DateTimeField(create_now=True, update_now=True) + expires_at = types.DateTimeField(required=False, allow_null=True) + last_used_at = types.DateTimeField(required=False, allow_null=True) - name: str = types.TextField(max_length=255, required=False) + name = types.TextField(max_length=255, required=False) - token: str = types.RandomStringField(length=40) + token = types.RandomStringField(length=40) - api_version: str = types.TextField(max_length=255, required=False) + api_version = types.TextField(max_length=255, required=False) query: postgres.QuerySet[APIKey] = postgres.QuerySet() diff --git a/plain-auth/tests/app/users/models.py b/plain-auth/tests/app/users/models.py index 30ed56d841..11d54a7f3b 100644 --- a/plain-auth/tests/app/users/models.py +++ b/plain-auth/tests/app/users/models.py @@ -4,5 +4,5 @@ @postgres.register_model class User(postgres.Model): - username: str = types.TextField(max_length=255) - is_admin: bool = types.BooleanField(default=False) + username = types.TextField(max_length=255) + is_admin = types.BooleanField(default=False) diff --git a/plain-cache/plain/cache/models.py b/plain-cache/plain/cache/models.py index 0e1468d98c..e6954990be 100644 --- a/plain-cache/plain/cache/models.py +++ b/plain-cache/plain/cache/models.py @@ -1,7 +1,6 @@ from __future__ import annotations -from datetime import datetime -from typing import Any, Self +from typing import Self from plain import postgres from plain.postgres import types @@ -24,11 +23,11 @@ def forever(self) -> Self: @postgres.register_model class CachedItem(postgres.Model): - key: str = types.TextField(max_length=255) - value: Any = types.JSONField(required=False, allow_null=True) - expires_at: datetime | None = types.DateTimeField(required=False, allow_null=True) - created_at: datetime = types.DateTimeField(create_now=True) - updated_at: datetime = types.DateTimeField(create_now=True, update_now=True) + key = types.TextField(max_length=255) + value = types.JSONField(required=False, allow_null=True) + expires_at = types.DateTimeField(required=False, allow_null=True) + created_at = types.DateTimeField(create_now=True) + updated_at = types.DateTimeField(create_now=True, update_now=True) query: CachedItemQuerySet = CachedItemQuerySet() diff --git a/plain-connect/tests/app/users/models.py b/plain-connect/tests/app/users/models.py index 10df65d667..3c775f5152 100644 --- a/plain-connect/tests/app/users/models.py +++ b/plain-connect/tests/app/users/models.py @@ -4,4 +4,4 @@ @postgres.register_model class User(postgres.Model): - username: str = types.TextField(max_length=255) + username = types.TextField(max_length=255) diff --git a/plain-flags/plain/flags/models.py b/plain-flags/plain/flags/models.py index c6bbbbc989..5a5b26289b 100644 --- a/plain-flags/plain/flags/models.py +++ b/plain-flags/plain/flags/models.py @@ -1,7 +1,6 @@ from __future__ import annotations import re -from datetime import datetime from plain import postgres from plain.exceptions import ValidationError @@ -17,10 +16,10 @@ def validate_flag_name(value: str) -> None: @postgres.register_model class FlagResult(postgres.Model): - created_at: datetime = types.DateTimeField(create_now=True) - updated_at: datetime = types.DateTimeField(create_now=True, update_now=True) + created_at = types.DateTimeField(create_now=True) + updated_at = types.DateTimeField(create_now=True, update_now=True) flag: Flag = types.ForeignKeyField("Flag", on_delete=postgres.CASCADE) - key: str = types.TextField(max_length=255) + key = types.TextField(max_length=255) value = types.JSONField() query: postgres.QuerySet[FlagResult] = postgres.QuerySet() @@ -39,19 +38,19 @@ def __str__(self) -> str: @postgres.register_model class Flag(postgres.Model): - created_at: datetime = types.DateTimeField(create_now=True) - updated_at: datetime = types.DateTimeField(create_now=True, update_now=True) - name: str = types.TextField(max_length=255, validators=[validate_flag_name]) + created_at = types.DateTimeField(create_now=True) + updated_at = types.DateTimeField(create_now=True, update_now=True) + name = types.TextField(max_length=255, validators=[validate_flag_name]) # Optional description that can be filled in after the flag is used/created - description: str = types.TextField(required=False) + description = types.TextField(required=False) # To manually disable a flag before completing deleting # (good to disable first to make sure the code doesn't use the flag anymore) - enabled: bool = types.BooleanField(default=True) + enabled = types.BooleanField(default=True) # To provide an easier way to see if a flag is still being used - used_at: datetime | None = types.DateTimeField(required=False, allow_null=True) + used_at = types.DateTimeField(required=False, allow_null=True) query: postgres.QuerySet[Flag] = postgres.QuerySet() diff --git a/plain-jobs/plain/jobs/models.py b/plain-jobs/plain/jobs/models.py index e6c4c901cb..897ea6734c 100644 --- a/plain-jobs/plain/jobs/models.py +++ b/plain-jobs/plain/jobs/models.py @@ -63,31 +63,25 @@ class JobRequest(postgres.Model): Keep all pending job requests in a single table. """ - created_at: datetime.datetime = types.DateTimeField(create_now=True) - uuid: UUID = types.UUIDField(generate=True) + created_at = types.DateTimeField(create_now=True) + uuid = types.UUIDField(generate=True) - job_class: str = types.TextField(max_length=255) - parameters: dict[str, Any] | None = types.JSONField(required=False, allow_null=True) - priority: int = types.SmallIntegerField(default=0) - source: str = types.TextField(required=False) - queue: str = types.TextField(default="default", max_length=255) + job_class = types.TextField(max_length=255) + parameters = types.JSONField(required=False, allow_null=True) + priority = types.SmallIntegerField(default=0) + source = types.TextField(required=False) + queue = types.TextField(default="default", max_length=255) - retries: int = types.SmallIntegerField(default=0) - retry_attempt: int = types.SmallIntegerField(default=0) + retries = types.SmallIntegerField(default=0) + retry_attempt = types.SmallIntegerField(default=0) - concurrency_key: str = types.TextField(max_length=255, required=False) + concurrency_key = types.TextField(max_length=255, required=False) - start_at: datetime.datetime | None = types.DateTimeField( - required=False, allow_null=True - ) + start_at = types.DateTimeField(required=False, allow_null=True) # OpenTelemetry trace context - trace_id: str | None = types.TextField( - max_length=34, required=False, allow_null=True - ) - span_id: str | None = types.TextField( - max_length=18, required=False, allow_null=True - ) + trace_id = types.TextField(max_length=34, required=False, allow_null=True) + span_id = types.TextField(max_length=18, required=False, allow_null=True) # expires_at = postgres.DateTimeField(required=False, allow_null=True) @@ -177,35 +171,27 @@ class JobProcess(postgres.Model): All active jobs are stored in this table. """ - uuid: UUID = types.UUIDField(generate=True) - created_at: datetime.datetime = types.DateTimeField(create_now=True) - started_at: datetime.datetime | None = types.DateTimeField( - required=False, allow_null=True - ) + uuid = types.UUIDField(generate=True) + created_at = types.DateTimeField(create_now=True) + started_at = types.DateTimeField(required=False, allow_null=True) # From the JobRequest - job_request_uuid: UUID = types.UUIDField() - requested_at: datetime.datetime | None = types.DateTimeField( - required=False, allow_null=True - ) - job_class: str = types.TextField(max_length=255) - parameters: dict[str, Any] | None = types.JSONField(required=False, allow_null=True) - priority: int = types.SmallIntegerField(default=0) - source: str = types.TextField(required=False) - queue: str = types.TextField(default="default", max_length=255) - retries: int = types.SmallIntegerField(default=0) - retry_attempt: int = types.SmallIntegerField(default=0) - concurrency_key: str = types.TextField(max_length=255, required=False) + job_request_uuid = types.UUIDField() + requested_at = types.DateTimeField(required=False, allow_null=True) + job_class = types.TextField(max_length=255) + parameters = types.JSONField(required=False, allow_null=True) + priority = types.SmallIntegerField(default=0) + source = types.TextField(required=False) + queue = types.TextField(default="default", max_length=255) + retries = types.SmallIntegerField(default=0) + retry_attempt = types.SmallIntegerField(default=0) + concurrency_key = types.TextField(max_length=255, required=False) # OpenTelemetry trace context - trace_id: str | None = types.TextField( - max_length=34, required=False, allow_null=True - ) - span_id: str | None = types.TextField( - max_length=18, required=False, allow_null=True - ) + trace_id = types.TextField(max_length=34, required=False, allow_null=True) + span_id = types.TextField(max_length=18, required=False, allow_null=True) - worker_id: UUID = types.UUIDField() + worker_id = types.UUIDField() query: JobQuerySet = JobQuerySet() @@ -302,11 +288,12 @@ def run(self) -> JobResult: links=links, ) as span: # This is how we know it has been picked up - self.started_at = timezone.now() + started_at = timezone.now() + self.started_at = started_at self.save(update_fields=["started_at"]) if self.requested_at: - queue_wait = (self.started_at - self.requested_at).total_seconds() + queue_wait = (started_at - self.requested_at).total_seconds() queue_wait_duration_histogram.record(queue_wait, metric_attributes) try: @@ -594,49 +581,37 @@ class JobResult(postgres.Model): All in-process and completed jobs are stored in this table. """ - uuid: UUID = types.UUIDField(generate=True) - created_at: datetime.datetime = types.DateTimeField(create_now=True) + uuid = types.UUIDField(generate=True) + created_at = types.DateTimeField(create_now=True) # From the Job - job_process_uuid: UUID = types.UUIDField() - started_at: datetime.datetime | None = types.DateTimeField( - required=False, allow_null=True - ) - ended_at: datetime.datetime | None = types.DateTimeField( - required=False, allow_null=True - ) - error: str = types.TextField(required=False) - status: str = types.TextField( + job_process_uuid = types.UUIDField() + started_at = types.DateTimeField(required=False, allow_null=True) + ended_at = types.DateTimeField(required=False, allow_null=True) + error = types.TextField(required=False) + status = types.TextField( max_length=20, choices=JobResultStatuses.choices, ) # From the JobRequest - job_request_uuid: UUID = types.UUIDField() - requested_at: datetime.datetime | None = types.DateTimeField( - required=False, allow_null=True - ) - job_class: str = types.TextField(max_length=255) - parameters: dict[str, Any] | None = types.JSONField(required=False, allow_null=True) - priority: int = types.SmallIntegerField(default=0) - source: str = types.TextField(required=False) - queue: str = types.TextField(default="default", max_length=255) - retries: int = types.SmallIntegerField(default=0) - retry_attempt: int = types.SmallIntegerField(default=0) - concurrency_key: str = types.TextField(max_length=255, required=False) + job_request_uuid = types.UUIDField() + requested_at = types.DateTimeField(required=False, allow_null=True) + job_class = types.TextField(max_length=255) + parameters = types.JSONField(required=False, allow_null=True) + priority = types.SmallIntegerField(default=0) + source = types.TextField(required=False) + queue = types.TextField(default="default", max_length=255) + retries = types.SmallIntegerField(default=0) + retry_attempt = types.SmallIntegerField(default=0) + concurrency_key = types.TextField(max_length=255, required=False) # Retries - retry_job_request_uuid: UUID | None = types.UUIDField( - required=False, allow_null=True - ) + retry_job_request_uuid = types.UUIDField(required=False, allow_null=True) # OpenTelemetry trace context - trace_id: str | None = types.TextField( - max_length=34, required=False, allow_null=True - ) - span_id: str | None = types.TextField( - max_length=18, required=False, allow_null=True - ) + trace_id = types.TextField(max_length=34, required=False, allow_null=True) + span_id = types.TextField(max_length=18, required=False, allow_null=True) query: JobResultQuerySet = JobResultQuerySet() @@ -747,12 +722,12 @@ class WorkerHeartbeat(postgres.Model): in-flight jobs. """ - worker_id: UUID = types.UUIDField() - hostname: str = types.TextField(max_length=255) - pid: int = types.IntegerField() - queues: list[str] = types.JSONField() - started_at: datetime.datetime = types.DateTimeField(create_now=True) - last_heartbeat_at: datetime.datetime = types.DateTimeField() + worker_id = types.UUIDField() + hostname = types.TextField(max_length=255) + pid = types.IntegerField() + queues = types.JSONField() + started_at = types.DateTimeField(create_now=True) + last_heartbeat_at = types.DateTimeField() model_options = postgres.Options( ordering=["-last_heartbeat_at"], diff --git a/plain-loginlink/tests/app/users/models.py b/plain-loginlink/tests/app/users/models.py index 79aa43058f..16dc1fe9e4 100644 --- a/plain-loginlink/tests/app/users/models.py +++ b/plain-loginlink/tests/app/users/models.py @@ -6,7 +6,7 @@ @postgres.register_model class User(postgres.Model): - email: str = types.EmailField() + email = types.EmailField() query: postgres.QuerySet[User] = postgres.QuerySet() diff --git a/plain-oauth/plain/oauth/models.py b/plain-oauth/plain/oauth/models.py index f40102e06e..39afa54878 100644 --- a/plain-oauth/plain/oauth/models.py +++ b/plain-oauth/plain/oauth/models.py @@ -1,6 +1,5 @@ from __future__ import annotations -import datetime from typing import TYPE_CHECKING, Any import psycopg @@ -21,10 +20,8 @@ @postgres.register_model class OAuthConnection(postgres.Model): - created_at: datetime.datetime = types.DateTimeField(create_now=True) - updated_at: datetime.datetime = types.DateTimeField( - create_now=True, update_now=True - ) + created_at = types.DateTimeField(create_now=True) + updated_at = types.DateTimeField(create_now=True, update_now=True) user = types.ForeignKeyField( "users.User", @@ -32,20 +29,16 @@ class OAuthConnection(postgres.Model): ) # The key used to refer to this provider type (in settings) - provider_key: str = types.TextField(max_length=100) + provider_key = types.TextField(max_length=100) # The unique ID of the user on the provider's system - provider_user_id: str = types.TextField(max_length=100) + provider_user_id = types.TextField(max_length=100) # Token data - access_token: str = types.EncryptedTextField(max_length=2000) - refresh_token: str = types.EncryptedTextField(max_length=2000, required=False) - access_token_expires_at: datetime.datetime | None = types.DateTimeField( - required=False, allow_null=True - ) - refresh_token_expires_at: datetime.datetime | None = types.DateTimeField( - required=False, allow_null=True - ) + access_token = types.EncryptedTextField(max_length=2000) + refresh_token = types.EncryptedTextField(max_length=2000, required=False) + access_token_expires_at = types.DateTimeField(required=False, allow_null=True) + refresh_token_expires_at = types.DateTimeField(required=False, allow_null=True) query: postgres.QuerySet[OAuthConnection] = postgres.QuerySet() diff --git a/plain-oauth/tests/app/users/models.py b/plain-oauth/tests/app/users/models.py index 3ff72bafc0..b8401c7f82 100644 --- a/plain-oauth/tests/app/users/models.py +++ b/plain-oauth/tests/app/users/models.py @@ -11,8 +11,8 @@ @postgres.register_model class User(postgres.Model): - email: str = types.EmailField() - username: str = types.TextField(max_length=100) + email = types.EmailField() + username = types.TextField(max_length=100) # Explicit reverse relation for OAuth connections oauth_connections: types.ReverseForeignKey[OAuthConnection] = ( diff --git a/plain-observer/plain/observer/models.py b/plain-observer/plain/observer/models.py index 9e6443b36f..2b2e6dc3e7 100644 --- a/plain-observer/plain/observer/models.py +++ b/plain-observer/plain/observer/models.py @@ -46,19 +46,19 @@ @postgres.register_model class Trace(postgres.Model): - trace_id: str = types.TextField(max_length=255) - start_time: datetime = types.DateTimeField() - end_time: datetime = types.DateTimeField() + trace_id = types.TextField(max_length=255) + start_time = types.DateTimeField() + end_time = types.DateTimeField() - root_span_name: str = types.TextField(default="", required=False) - summary: str = types.TextField(max_length=255, default="", required=False) + root_span_name = types.TextField(default="", required=False) + summary = types.TextField(max_length=255, default="", required=False) # Plain fields - request_id: str = types.TextField(max_length=255, default="", required=False) - session_id: str = types.TextField(max_length=255, default="", required=False) - user_id: str = types.TextField(max_length=255, default="", required=False) - app_name: str = types.TextField(max_length=255, default="", required=False) - app_version: str = types.TextField(max_length=255, default="", required=False) + request_id = types.TextField(max_length=255, default="", required=False) + session_id = types.TextField(max_length=255, default="", required=False) + user_id = types.TextField(max_length=255, default="", required=False) + app_name = types.TextField(max_length=255, default="", required=False) + app_version = types.TextField(max_length=255, default="", required=False) # Explicit reverse relations spans: types.ReverseForeignKey[Span] = types.ReverseForeignKey( @@ -330,15 +330,15 @@ def annotate_spans(self) -> list[Span]: class Span(postgres.Model): trace: Trace = types.ForeignKeyField(Trace, on_delete=postgres.CASCADE) - span_id: str = types.TextField(max_length=255) + span_id = types.TextField(max_length=255) - name: str = types.TextField(max_length=255) - kind: str = types.TextField(max_length=50) - parent_id: str = types.TextField(max_length=255, default="", required=False) - start_time: datetime = types.DateTimeField() - end_time: datetime = types.DateTimeField() - status: str = types.TextField(max_length=50, default="", required=False) - span_data: dict = types.JSONField(default={}, required=False) + name = types.TextField(max_length=255) + kind = types.TextField(max_length=50) + parent_id = types.TextField(max_length=255, default="", required=False) + start_time = types.DateTimeField() + end_time = types.DateTimeField() + status = types.TextField(max_length=50, default="", required=False) + span_data = types.JSONField(default={}, required=False) # Explicit reverse relation logs: types.ReverseForeignKey[Log] = types.ReverseForeignKey(to="Log", field="span") @@ -514,9 +514,9 @@ class Log(postgres.Model): required=False, ) - timestamp: datetime = types.DateTimeField() - level: str = types.TextField(max_length=20) - message: str = types.TextField() + timestamp = types.DateTimeField() + level = types.TextField(max_length=20) + message = types.TextField() query: postgres.QuerySet[Log] = postgres.QuerySet() diff --git a/plain-passwords/tests/app/users/models.py b/plain-passwords/tests/app/users/models.py index e544fa79b6..31b622c818 100644 --- a/plain-passwords/tests/app/users/models.py +++ b/plain-passwords/tests/app/users/models.py @@ -7,7 +7,7 @@ @postgres.register_model class User(postgres.Model): - email: str = types.EmailField() + email = types.EmailField() password: str = PasswordField() query: postgres.QuerySet[User] = postgres.QuerySet() diff --git a/plain-postgres/plain/postgres/base.py b/plain-postgres/plain/postgres/base.py index 6dc0ff1e43..061ac334c6 100644 --- a/plain-postgres/plain/postgres/base.py +++ b/plain-postgres/plain/postgres/base.py @@ -83,7 +83,7 @@ def __init__(self) -> None: class Model(metaclass=ModelBase): # Every model gets an automatic id field - id: int = types.PrimaryKeyField() + id = types.PrimaryKeyField() # Descriptors for other model behavior query: QuerySet[Self] = QuerySet() diff --git a/plain-postgres/plain/postgres/fields/base.py b/plain-postgres/plain/postgres/fields/base.py index db1aeaf2d3..6e9cc172ff 100644 --- a/plain-postgres/plain/postgres/fields/base.py +++ b/plain-postgres/plain/postgres/fields/base.py @@ -17,7 +17,7 @@ from plain.postgres.constants import LOOKUP_SEP from plain.postgres.dialect import quote_name from plain.postgres.enums import ChoicesMeta -from plain.postgres.query_utils import RegisterLookupMixin +from plain.postgres.query_utils import Q, RegisterLookupMixin from plain.preflight import PreflightResult from plain.utils.datastructures import DictWrapper from plain.utils.functional import Promise @@ -153,6 +153,42 @@ def __repr__(self) -> str: return f"<{path}: {name}>" return f"<{path}>" + # Typed query conditions. Available on every field; subclasses extend + # with type-specific lookups (comparison on numeric, string ops on text). + def equals(self, value: T) -> Q: + return self._build_q("", value) + + def not_equal(self, value: T) -> Q: + return ~self._build_q("", value) + + def gt(self, value: T) -> Q: + return self._build_q("gt", value) + + def gte(self, value: T) -> Q: + return self._build_q("gte", value) + + def lt(self, value: T) -> Q: + return self._build_q("lt", value) + + def lte(self, value: T) -> Q: + return self._build_q("lte", value) + + def is_null(self, value: bool = True) -> Q: + return self._build_q("isnull", value) + + def _build_q(self, suffix: str, value: Any) -> Q: + """Build a Q from a lookup suffix + value, bypassing Q's reserved + `_connector`/`_negated` kwargs that confuse the type checker on + `**{name: value}` expansion.""" + assert self.name is not None, ( + "Field name must be set before building a query condition; " + "the field must be attached to a model." + ) + name = f"{self.name}__{suffix}" if suffix else self.name + q = Q() + q.children.append((name, value)) + return q + def preflight(self, **kwargs: Any) -> list[PreflightResult]: return [*self._check_field_name()] diff --git a/plain-postgres/plain/postgres/fields/binary.py b/plain-postgres/plain/postgres/fields/binary.py index 8f26d12fb2..6a60dfe275 100644 --- a/plain-postgres/plain/postgres/fields/binary.py +++ b/plain-postgres/plain/postgres/fields/binary.py @@ -15,7 +15,9 @@ from plain.postgres.sql.compiler import SQLCompiler -class BinaryField(ColumnField[bytes | memoryview]): +class BinaryField[ + T: (bytes | memoryview, bytes | memoryview | None) = bytes | memoryview +](ColumnField[T]): db_type_sql = "bytea" empty_values = [None, b""] _default_empty_value = b"" diff --git a/plain-postgres/plain/postgres/fields/boolean.py b/plain-postgres/plain/postgres/fields/boolean.py index a30c943d7e..67299e7196 100644 --- a/plain-postgres/plain/postgres/fields/boolean.py +++ b/plain-postgres/plain/postgres/fields/boolean.py @@ -7,7 +7,7 @@ from .base import DefaultableField -class BooleanField(DefaultableField[bool]): +class BooleanField[T: (bool, bool | None) = bool](DefaultableField[T]): db_type_sql = "boolean" empty_strings_allowed = False diff --git a/plain-postgres/plain/postgres/fields/duration.py b/plain-postgres/plain/postgres/fields/duration.py index 2c0e23ab1b..1e46ada429 100644 --- a/plain-postgres/plain/postgres/fields/duration.py +++ b/plain-postgres/plain/postgres/fields/duration.py @@ -13,7 +13,9 @@ from plain.postgres.connection import DatabaseConnection -class DurationField(DefaultableField[datetime.timedelta]): +class DurationField[ + T: (datetime.timedelta, datetime.timedelta | None) = datetime.timedelta +](DefaultableField[T]): """Store timedelta objects using PostgreSQL's interval type.""" db_type_sql = "interval" diff --git a/plain-postgres/plain/postgres/fields/encrypted.py b/plain-postgres/plain/postgres/fields/encrypted.py index 65f1de1308..6d45a0509b 100644 --- a/plain-postgres/plain/postgres/fields/encrypted.py +++ b/plain-postgres/plain/postgres/fields/encrypted.py @@ -178,7 +178,9 @@ def _check_encrypted_constraints(self) -> list[PreflightResult]: return errors -class EncryptedTextField(EncryptedFieldMixin, ColumnField[str]): +class EncryptedTextField[T: (str, str | None) = str]( + EncryptedFieldMixin, ColumnField[T] +): """A text field that encrypts its value before storing in the database. Values are encrypted using Fernet (AES-128-CBC + HMAC-SHA256) with a key diff --git a/plain-postgres/plain/postgres/fields/network.py b/plain-postgres/plain/postgres/fields/network.py index 6b70a0de37..d0ff56f2b8 100644 --- a/plain-postgres/plain/postgres/fields/network.py +++ b/plain-postgres/plain/postgres/fields/network.py @@ -15,7 +15,7 @@ from plain.postgres.connection import DatabaseConnection -class GenericIPAddressField(DefaultableField[str]): +class GenericIPAddressField[T: (str, str | None) = str](DefaultableField[T]): db_type_sql = "inet" empty_strings_allowed = False diff --git a/plain-postgres/plain/postgres/fields/numeric.py b/plain-postgres/plain/postgres/fields/numeric.py index 85f1d456fc..991b3bce4b 100644 --- a/plain-postgres/plain/postgres/fields/numeric.py +++ b/plain-postgres/plain/postgres/fields/numeric.py @@ -16,7 +16,7 @@ from plain.postgres.connection import DatabaseConnection -class FloatField(DefaultableField[float]): +class FloatField[T: (float, float | None) = float](DefaultableField[T]): db_type_sql = "double precision" empty_strings_allowed = False @@ -44,7 +44,7 @@ def to_python(self, value: Any) -> float | None: ) -class IntegerField(DefaultableField[int]): +class IntegerField[T: (int, int | None) = int](DefaultableField[T]): db_type_sql = "integer" integer_range: tuple[int, int] = (-2147483648, 2147483647) psycopg_type: type = numeric.Int4 @@ -118,19 +118,21 @@ def to_python(self, value: Any) -> int | None: ) -class BigIntegerField(IntegerField): +class BigIntegerField[T: (int, int | None) = int](IntegerField[T]): db_type_sql = "bigint" integer_range = (-9223372036854775808, 9223372036854775807) psycopg_type = numeric.Int8 -class SmallIntegerField(IntegerField): +class SmallIntegerField[T: (int, int | None) = int](IntegerField[T]): db_type_sql = "smallint" integer_range = (-32768, 32767) psycopg_type = numeric.Int2 -class DecimalField(DefaultableField[decimal.Decimal]): +class DecimalField[T: (decimal.Decimal, decimal.Decimal | None) = decimal.Decimal]( + DefaultableField[T] +): db_type_sql = "numeric(%(max_digits)s,%(decimal_places)s)" empty_strings_allowed = False diff --git a/plain-postgres/plain/postgres/fields/temporal.py b/plain-postgres/plain/postgres/fields/temporal.py index 9e57695e7c..1be1b1aad8 100644 --- a/plain-postgres/plain/postgres/fields/temporal.py +++ b/plain-postgres/plain/postgres/fields/temporal.py @@ -69,7 +69,9 @@ def _check_if_value_fixed( return [] -class DateField(DefaultableField[datetime.date]): +class DateField[T: (datetime.date, datetime.date | None) = datetime.date]( + DefaultableField[T] +): db_type_sql = "date" empty_strings_allowed = False @@ -149,7 +151,9 @@ def get_db_prep_value( return value -class DateTimeField(ColumnField[datetime.datetime]): +class DateTimeField[ + T: (datetime.datetime, datetime.datetime | None) = datetime.datetime +](ColumnField[T]): db_type_sql = "timestamp with time zone" empty_strings_allowed = False @@ -302,7 +306,9 @@ def get_db_prep_value( return value -class TimeField(DefaultableField[datetime.time]): +class TimeField[T: (datetime.time, datetime.time | None) = datetime.time]( + DefaultableField[T] +): db_type_sql = "time without time zone" empty_strings_allowed = False diff --git a/plain-postgres/plain/postgres/fields/text.py b/plain-postgres/plain/postgres/fields/text.py index 7bf98ce566..03ab087885 100644 --- a/plain-postgres/plain/postgres/fields/text.py +++ b/plain-postgres/plain/postgres/fields/text.py @@ -11,9 +11,10 @@ if TYPE_CHECKING: from plain.postgres.functions.random import RandomString + from plain.postgres.query_utils import Q -class TextField(ChoicesField[str]): +class TextField[T: (str, str | None) = str](ChoicesField[T]): db_type_sql = "text" def __init__( @@ -86,16 +87,28 @@ def get_prep_value(self, value: Any) -> Any: value = super().get_prep_value(value) return self.to_python(value) + def contains(self, value: str) -> Q: + return self._build_q("contains", value) -class EmailField(TextField): + def icontains(self, value: str) -> Q: + return self._build_q("icontains", value) + + def startswith(self, value: str) -> Q: + return self._build_q("startswith", value) + + def endswith(self, value: str) -> Q: + return self._build_q("endswith", value) + + +class EmailField[T: (str, str | None) = str](TextField[T]): default_validators = [validators.validate_email] -class URLField(TextField): +class URLField[T: (str, str | None) = str](TextField[T]): default_validators = [validators.URLValidator()] -class RandomStringField(ColumnField[str]): +class RandomStringField[T: (str, str | None) = str](ColumnField[T]): """Text column whose value is a Postgres-generated random hex string. The column carries a ``DEFAULT`` that evaluates per row, so raw SQL and diff --git a/plain-postgres/plain/postgres/fields/timezones.py b/plain-postgres/plain/postgres/fields/timezones.py index fc94f0a3d9..fa2fd19ba1 100644 --- a/plain-postgres/plain/postgres/fields/timezones.py +++ b/plain-postgres/plain/postgres/fields/timezones.py @@ -47,7 +47,9 @@ def _get_canonical_timezones() -> frozenset[str]: ) -class TimeZoneField(ChoicesField[zoneinfo.ZoneInfo]): +class TimeZoneField[ + T: (zoneinfo.ZoneInfo, zoneinfo.ZoneInfo | None) = zoneinfo.ZoneInfo +](ChoicesField[T]): """ A model field that stores timezone names as strings but provides ZoneInfo objects. diff --git a/plain-postgres/plain/postgres/fields/uuid.py b/plain-postgres/plain/postgres/fields/uuid.py index 6f06614162..faa3170b25 100644 --- a/plain-postgres/plain/postgres/fields/uuid.py +++ b/plain-postgres/plain/postgres/fields/uuid.py @@ -14,7 +14,7 @@ from plain.postgres.expressions import Func -class UUIDField(ColumnField[UUID]): +class UUIDField[T: (UUID, UUID | None) = UUID](ColumnField[T]): db_type_sql = "uuid" empty_strings_allowed = False diff --git a/plain-postgres/plain/postgres/query.py b/plain-postgres/plain/postgres/query.py index 2cb8474a2f..b086cb75f2 100644 --- a/plain-postgres/plain/postgres/query.py +++ b/plain-postgres/plain/postgres/query.py @@ -1171,6 +1171,18 @@ def exclude(self, *args: Any, **kwargs: Any) -> Self: """ return self._filter_or_exclude(True, args, kwargs) + def where(self, *conditions: Q) -> Self: + """ + Return a new QuerySet narrowed by typed field conditions. + + Conditions are produced by field methods like `Model.field.equals(...)` + and combine with `|` and `&`. Unlike `filter()`, this accepts no + keyword arguments — every condition is a typed expression, so a + type checker can reject typos and value-type mismatches at the call + site. + """ + return self._filter_or_exclude(False, conditions, {}) + def _filter_or_exclude( self, negate: bool, args: tuple[Any, ...], kwargs: dict[str, Any] ) -> Self: diff --git a/plain-postgres/plain/postgres/types.pyi b/plain-postgres/plain/postgres/types.pyi index fde8abfc13..49da755feb 100644 --- a/plain-postgres/plain/postgres/types.pyi +++ b/plain-postgres/plain/postgres/types.pyi @@ -1,32 +1,59 @@ """ Type stubs for typed model fields. -These stubs tell type checkers that field constructors return primitive types, -enabling typed model definitions like: - name: str = types.TextField() +These stubs tell type checkers that each field constructor returns the +typed *descriptor* (`XField[T]`), not the primitive `T`. Combined with +`Field.__get__`'s overloads, this gives you: -At runtime, these are Field instances (descriptors), but type checkers see the primitives. + class User(postgres.Model): + email = types.EmailField() + age = types.IntegerField(allow_null=True) -The return type is conditional on allow_null: -- allow_null=False (default) returns the primitive type (e.g., str) -- allow_null=True returns the primitive type | None (e.g., str | None) + User.email # EmailField[str] — typed reference, has .equals(), .contains(), ... + user.email # str — the loaded value + User.age # IntegerField[int | None] + user.age # int | None + +The return type is parameterized by nullability: +- allow_null=False (default) → XField[T] +- allow_null=True → XField[T | None] """ from collections.abc import Callable, Sequence from datetime import date, datetime, time, timedelta from decimal import Decimal -from json import JSONDecoder, JSONEncoder from typing import Any, Literal, overload from uuid import UUID from zoneinfo import ZoneInfo -# Import manager types from runtime (will be Generic[T, QS] there) from plain.postgres.base import Model from plain.postgres.deletion import OnDelete +from plain.postgres.fields.binary import BinaryField as _BinaryField +from plain.postgres.fields.boolean import BooleanField as _BooleanField +from plain.postgres.fields.duration import DurationField as _DurationField +from plain.postgres.fields.encrypted import EncryptedTextField as _EncryptedTextField +from plain.postgres.fields.network import ( + GenericIPAddressField as _GenericIPAddressField, +) +from plain.postgres.fields.numeric import BigIntegerField as _BigIntegerField +from plain.postgres.fields.numeric import DecimalField as _DecimalField +from plain.postgres.fields.numeric import FloatField as _FloatField +from plain.postgres.fields.numeric import IntegerField as _IntegerField +from plain.postgres.fields.numeric import SmallIntegerField as _SmallIntegerField +from plain.postgres.fields.primary_key import PrimaryKeyField as _PrimaryKeyField from plain.postgres.fields.related_managers import ( ManyToManyManager, ReverseForeignKeyManager, ) +from plain.postgres.fields.temporal import DateField as _DateField +from plain.postgres.fields.temporal import DateTimeField as _DateTimeField +from plain.postgres.fields.temporal import TimeField as _TimeField +from plain.postgres.fields.text import EmailField as _EmailField +from plain.postgres.fields.text import RandomStringField as _RandomStringField +from plain.postgres.fields.text import TextField as _TextField +from plain.postgres.fields.text import URLField as _URLField +from plain.postgres.fields.timezones import TimeZoneField as _TimeZoneField +from plain.postgres.fields.uuid import UUIDField as _UUIDField from plain.postgres.query import QuerySet # String fields @@ -39,7 +66,7 @@ def TextField( default: Any = ..., choices: Any = None, validators: Sequence[Callable[..., Any]] = (), -) -> str | None: ... +) -> _TextField[str | None]: ... @overload def TextField( *, @@ -49,7 +76,7 @@ def TextField( default: Any = ..., choices: Any = None, validators: Sequence[Callable[..., Any]] = (), -) -> str: ... +) -> _TextField[str]: ... @overload def EmailField( *, @@ -59,7 +86,7 @@ def EmailField( default: Any = ..., choices: Any = None, validators: Sequence[Callable[..., Any]] = (), -) -> str | None: ... +) -> _EmailField[str | None]: ... @overload def EmailField( *, @@ -69,7 +96,7 @@ def EmailField( default: Any = ..., choices: Any = None, validators: Sequence[Callable[..., Any]] = (), -) -> str: ... +) -> _EmailField[str]: ... @overload def URLField( *, @@ -79,7 +106,7 @@ def URLField( default: Any = ..., choices: Any = None, validators: Sequence[Callable[..., Any]] = (), -) -> str | None: ... +) -> _URLField[str | None]: ... @overload def URLField( *, @@ -89,7 +116,7 @@ def URLField( default: Any = ..., choices: Any = None, validators: Sequence[Callable[..., Any]] = (), -) -> str: ... +) -> _URLField[str]: ... # Integer fields @overload @@ -99,7 +126,7 @@ def IntegerField( allow_null: Literal[True], default: Any = ..., validators: Sequence[Callable[..., Any]] = (), -) -> int | None: ... +) -> _IntegerField[int | None]: ... @overload def IntegerField( *, @@ -107,7 +134,7 @@ def IntegerField( allow_null: Literal[False] = False, default: Any = ..., validators: Sequence[Callable[..., Any]] = (), -) -> int: ... +) -> _IntegerField[int]: ... @overload def BigIntegerField( *, @@ -115,7 +142,7 @@ def BigIntegerField( allow_null: Literal[True], default: Any = ..., validators: Sequence[Callable[..., Any]] = (), -) -> int | None: ... +) -> _BigIntegerField[int | None]: ... @overload def BigIntegerField( *, @@ -123,7 +150,7 @@ def BigIntegerField( allow_null: Literal[False] = False, default: Any = ..., validators: Sequence[Callable[..., Any]] = (), -) -> int: ... +) -> _BigIntegerField[int]: ... @overload def SmallIntegerField( *, @@ -131,7 +158,7 @@ def SmallIntegerField( allow_null: Literal[True], default: Any = ..., validators: Sequence[Callable[..., Any]] = (), -) -> int | None: ... +) -> _SmallIntegerField[int | None]: ... @overload def SmallIntegerField( *, @@ -139,8 +166,8 @@ def SmallIntegerField( allow_null: Literal[False] = False, default: Any = ..., validators: Sequence[Callable[..., Any]] = (), -) -> int: ... -def PrimaryKeyField() -> int: ... +) -> _SmallIntegerField[int]: ... +def PrimaryKeyField() -> _PrimaryKeyField: ... # Numeric fields @overload @@ -150,7 +177,7 @@ def FloatField( allow_null: Literal[True], default: Any = ..., validators: Sequence[Callable[..., Any]] = (), -) -> float | None: ... +) -> _FloatField[float | None]: ... @overload def FloatField( *, @@ -158,7 +185,7 @@ def FloatField( allow_null: Literal[False] = False, default: Any = ..., validators: Sequence[Callable[..., Any]] = (), -) -> float: ... +) -> _FloatField[float]: ... @overload def DecimalField( *, @@ -168,7 +195,7 @@ def DecimalField( allow_null: Literal[True], default: Any = ..., validators: Sequence[Callable[..., Any]] = (), -) -> Decimal | None: ... +) -> _DecimalField[Decimal | None]: ... @overload def DecimalField( *, @@ -178,7 +205,7 @@ def DecimalField( allow_null: Literal[False] = False, default: Any = ..., validators: Sequence[Callable[..., Any]] = (), -) -> Decimal: ... +) -> _DecimalField[Decimal]: ... # Boolean field @overload @@ -188,7 +215,7 @@ def BooleanField( allow_null: Literal[True], default: Any = ..., validators: Sequence[Callable[..., Any]] = (), -) -> bool | None: ... +) -> _BooleanField[bool | None]: ... @overload def BooleanField( *, @@ -196,7 +223,7 @@ def BooleanField( allow_null: Literal[False] = False, default: Any = ..., validators: Sequence[Callable[..., Any]] = (), -) -> bool: ... +) -> _BooleanField[bool]: ... # Date/time fields @overload @@ -206,7 +233,7 @@ def DateField( allow_null: Literal[True], default: Any = ..., validators: Sequence[Callable[..., Any]] = (), -) -> date | None: ... +) -> _DateField[date | None]: ... @overload def DateField( *, @@ -214,7 +241,7 @@ def DateField( allow_null: Literal[False] = False, default: Any = ..., validators: Sequence[Callable[..., Any]] = (), -) -> date: ... +) -> _DateField[date]: ... @overload def DateTimeField( *, @@ -223,7 +250,7 @@ def DateTimeField( required: bool = True, allow_null: Literal[True], validators: Sequence[Callable[..., Any]] = (), -) -> datetime | None: ... +) -> _DateTimeField[datetime | None]: ... @overload def DateTimeField( *, @@ -232,7 +259,7 @@ def DateTimeField( required: bool = True, allow_null: Literal[False] = False, validators: Sequence[Callable[..., Any]] = (), -) -> datetime: ... +) -> _DateTimeField[datetime]: ... @overload def TimeField( *, @@ -240,7 +267,7 @@ def TimeField( allow_null: Literal[True], default: Any = ..., validators: Sequence[Callable[..., Any]] = (), -) -> time | None: ... +) -> _TimeField[time | None]: ... @overload def TimeField( *, @@ -248,7 +275,7 @@ def TimeField( allow_null: Literal[False] = False, default: Any = ..., validators: Sequence[Callable[..., Any]] = (), -) -> time: ... +) -> _TimeField[time]: ... @overload def DurationField( *, @@ -256,7 +283,7 @@ def DurationField( allow_null: Literal[True], default: Any = ..., validators: Sequence[Callable[..., Any]] = (), -) -> timedelta | None: ... +) -> _DurationField[timedelta | None]: ... @overload def DurationField( *, @@ -264,7 +291,7 @@ def DurationField( allow_null: Literal[False] = False, default: Any = ..., validators: Sequence[Callable[..., Any]] = (), -) -> timedelta: ... +) -> _DurationField[timedelta]: ... @overload def TimeZoneField( *, @@ -272,7 +299,7 @@ def TimeZoneField( allow_null: Literal[True], default: Any = ..., validators: Sequence[Callable[..., Any]] = (), -) -> ZoneInfo | None: ... +) -> _TimeZoneField[ZoneInfo | None]: ... @overload def TimeZoneField( *, @@ -280,7 +307,7 @@ def TimeZoneField( allow_null: Literal[False] = False, default: Any = ..., validators: Sequence[Callable[..., Any]] = (), -) -> ZoneInfo: ... +) -> _TimeZoneField[ZoneInfo]: ... # Other fields @overload @@ -290,7 +317,7 @@ def UUIDField( required: bool = True, allow_null: Literal[True], validators: Sequence[Callable[..., Any]] = (), -) -> UUID | None: ... +) -> _UUIDField[UUID | None]: ... @overload def UUIDField( *, @@ -298,7 +325,7 @@ def UUIDField( required: bool = True, allow_null: Literal[False] = False, validators: Sequence[Callable[..., Any]] = (), -) -> UUID: ... +) -> _UUIDField[UUID]: ... @overload def RandomStringField( *, @@ -306,7 +333,7 @@ def RandomStringField( required: bool = True, allow_null: Literal[True], validators: Sequence[Callable[..., Any]] = (), -) -> str | None: ... +) -> _RandomStringField[str | None]: ... @overload def RandomStringField( *, @@ -314,7 +341,7 @@ def RandomStringField( required: bool = True, allow_null: Literal[False] = False, validators: Sequence[Callable[..., Any]] = (), -) -> str: ... +) -> _RandomStringField[str]: ... @overload def BinaryField( *, @@ -322,7 +349,7 @@ def BinaryField( required: bool = True, allow_null: Literal[True], validators: Sequence[Callable[..., Any]] = (), -) -> bytes | None: ... +) -> _BinaryField[bytes | memoryview | None]: ... @overload def BinaryField( *, @@ -330,7 +357,7 @@ def BinaryField( required: bool = True, allow_null: Literal[False] = False, validators: Sequence[Callable[..., Any]] = (), -) -> bytes: ... +) -> _BinaryField[bytes | memoryview]: ... @overload def GenericIPAddressField( *, @@ -340,7 +367,7 @@ def GenericIPAddressField( allow_null: Literal[True], default: Any = ..., validators: Sequence[Callable[..., Any]] = (), -) -> str | None: ... +) -> _GenericIPAddressField[str | None]: ... @overload def GenericIPAddressField( *, @@ -350,12 +377,12 @@ def GenericIPAddressField( allow_null: Literal[False] = False, default: Any = ..., validators: Sequence[Callable[..., Any]] = (), -) -> str: ... +) -> _GenericIPAddressField[str]: ... @overload def JSONField( *, - encoder: type[JSONEncoder] | None = None, - decoder: type[JSONDecoder] | None = None, + encoder: Any = None, + decoder: Any = None, required: bool = True, allow_null: Literal[True], default: Any = ..., @@ -364,8 +391,8 @@ def JSONField( @overload def JSONField( *, - encoder: type[JSONEncoder] | None = None, - decoder: type[JSONDecoder] | None = None, + encoder: Any = None, + decoder: Any = None, required: bool = True, allow_null: Literal[False] = False, default: Any = ..., @@ -380,7 +407,7 @@ def EncryptedTextField( required: bool = True, allow_null: Literal[True], validators: Sequence[Callable[..., Any]] = (), -) -> str | None: ... +) -> _EncryptedTextField[str | None]: ... @overload def EncryptedTextField( *, @@ -388,12 +415,12 @@ def EncryptedTextField( required: bool = True, allow_null: Literal[False] = False, validators: Sequence[Callable[..., Any]] = (), -) -> str: ... +) -> _EncryptedTextField[str]: ... @overload def EncryptedJSONField( *, - encoder: type[JSONEncoder] | None = None, - decoder: type[JSONDecoder] | None = None, + encoder: Any = None, + decoder: Any = None, required: bool = True, allow_null: Literal[True], validators: Sequence[Callable[..., Any]] = (), @@ -401,8 +428,8 @@ def EncryptedJSONField( @overload def EncryptedJSONField( *, - encoder: type[JSONEncoder] | None = None, - decoder: type[JSONDecoder] | None = None, + encoder: Any = None, + decoder: Any = None, required: bool = True, allow_null: Literal[False] = False, validators: Sequence[Callable[..., Any]] = (), diff --git a/plain-postgres/tests/app/examples/models/constraints.py b/plain-postgres/tests/app/examples/models/constraints.py index 7621e0fbce..38105a3250 100644 --- a/plain-postgres/tests/app/examples/models/constraints.py +++ b/plain-postgres/tests/app/examples/models/constraints.py @@ -13,8 +13,8 @@ class ConstraintExample(postgres.Model): fixture without polluting other models' schemas. """ - name: str = types.TextField(max_length=100) - description: str = types.TextField(max_length=100) + name = types.TextField(max_length=100) + description = types.TextField(max_length=100) query: postgres.QuerySet[ConstraintExample] = postgres.QuerySet() diff --git a/plain-postgres/tests/app/examples/models/defaults.py b/plain-postgres/tests/app/examples/models/defaults.py index 51402493d3..26522bce9b 100644 --- a/plain-postgres/tests/app/examples/models/defaults.py +++ b/plain-postgres/tests/app/examples/models/defaults.py @@ -1,8 +1,5 @@ from __future__ import annotations -import uuid -from datetime import datetime - from plain import postgres from plain.postgres import types @@ -12,13 +9,13 @@ class DefaultsExample(postgres.Model): """Exercises Python-side literal `default=` semantics (static values, explicit overrides).""" - name: str = types.TextField(max_length=100) + name = types.TextField(max_length=100) # Static string default - status: str = types.TextField(max_length=20, default="pending") + status = types.TextField(max_length=20, default="pending") # Static int default - priority: int = types.IntegerField(default=5) + priority = types.IntegerField(default=5) # Nullable with a non-null default — for testing explicit-None override - note: str | None = types.TextField( + note = types.TextField( max_length=100, default="auto", allow_null=True, required=False ) @@ -29,13 +26,13 @@ class DefaultsExample(postgres.Model): class DBDefaultsExample(postgres.Model): """Model exercising DB-expression defaults (fields-db-defaults Phase 1).""" - name: str = types.TextField(max_length=100) + name = types.TextField(max_length=100) # Expression default — rendered as `DEFAULT gen_random_uuid()` in DDL - db_uuid: uuid.UUID = types.UUIDField(generate=True) + db_uuid = types.UUIDField(generate=True) # Expression default — rendered as `DEFAULT STATEMENT_TIMESTAMP()` in DDL - created_at: datetime = types.DateTimeField(create_now=True) + created_at = types.DateTimeField(create_now=True) # Expression default — per-row random string generated by Postgres - token: str = types.RandomStringField(length=16) + token = types.RandomStringField(length=16) query: postgres.QuerySet[DBDefaultsExample] = postgres.QuerySet() diff --git a/plain-postgres/tests/app/examples/models/delete.py b/plain-postgres/tests/app/examples/models/delete.py index 5e3e99cd12..88710ef3eb 100644 --- a/plain-postgres/tests/app/examples/models/delete.py +++ b/plain-postgres/tests/app/examples/models/delete.py @@ -13,7 +13,7 @@ @postgres.register_model class DeleteParent(postgres.Model): - name: str = types.TextField(max_length=100) + name = types.TextField(max_length=100) query: postgres.QuerySet[DeleteParent] = postgres.QuerySet() @@ -89,7 +89,7 @@ def from_model(cls, model, query=None): @postgres.register_model class HideableItem(postgres.Model): - name: str = types.TextField(max_length=100) + name = types.TextField(max_length=100) query = _HideGhostsQuerySet() @@ -101,7 +101,7 @@ class HideableItem(postgres.Model): @postgres.register_model class Grandparent(postgres.Model): - name: str = types.TextField(max_length=100) + name = types.TextField(max_length=100) query: postgres.QuerySet[Grandparent] = postgres.QuerySet() @@ -129,14 +129,14 @@ class Grandchild(postgres.Model): @postgres.register_model class DiamondParentA(postgres.Model): - name: str = types.TextField(max_length=100) + name = types.TextField(max_length=100) query: postgres.QuerySet[DiamondParentA] = postgres.QuerySet() @postgres.register_model class DiamondParentB(postgres.Model): - name: str = types.TextField(max_length=100) + name = types.TextField(max_length=100) query: postgres.QuerySet[DiamondParentB] = postgres.QuerySet() @@ -161,7 +161,7 @@ class DiamondChild(postgres.Model): @postgres.register_model class CircA(postgres.Model): - name: str = types.TextField(max_length=100) + name = types.TextField(max_length=100) partner: CircB | None = types.ForeignKeyField( "CircB", on_delete=postgres.CASCADE, @@ -173,7 +173,7 @@ class CircA(postgres.Model): @postgres.register_model class CircB(postgres.Model): - name: str = types.TextField(max_length=100) + name = types.TextField(max_length=100) partner: CircA | None = types.ForeignKeyField( CircA, on_delete=postgres.CASCADE, diff --git a/plain-postgres/tests/app/examples/models/encrypted.py b/plain-postgres/tests/app/examples/models/encrypted.py index 58a1cde6bc..18e2ca470b 100644 --- a/plain-postgres/tests/app/examples/models/encrypted.py +++ b/plain-postgres/tests/app/examples/models/encrypted.py @@ -8,9 +8,9 @@ class SecretStore(postgres.Model): """Model for testing encrypted fields.""" - name: str = types.TextField(max_length=100) - api_key: str = types.EncryptedTextField(max_length=200) - notes: str = types.EncryptedTextField(required=False) - config: dict = types.EncryptedJSONField(required=False, allow_null=True) + name = types.TextField(max_length=100) + api_key = types.EncryptedTextField(max_length=200) + notes = types.EncryptedTextField(required=False) + config = types.EncryptedJSONField(required=False, allow_null=True) query: postgres.QuerySet[SecretStore] = postgres.QuerySet() diff --git a/plain-postgres/tests/app/examples/models/forms.py b/plain-postgres/tests/app/examples/models/forms.py index 6505771e33..33d70cc7b9 100644 --- a/plain-postgres/tests/app/examples/models/forms.py +++ b/plain-postgres/tests/app/examples/models/forms.py @@ -1,9 +1,5 @@ from __future__ import annotations -import datetime -import uuid -from decimal import Decimal - from plain import postgres from plain.postgres import types @@ -15,21 +11,21 @@ class FormsExample(postgres.Model): to guard against regressions in modelfield_to_formfield(). """ - name: str = types.TextField(max_length=100) - status: str = types.TextField( + name = types.TextField(max_length=100) + status = types.TextField( max_length=20, choices=[("draft", "Draft"), ("published", "Published")], default="draft", ) - note: str | None = types.TextField(max_length=200, allow_null=True, required=False) - count: int = types.IntegerField() - ratio: float = types.FloatField() - amount: Decimal = types.DecimalField(max_digits=10, decimal_places=2) - is_active: bool = types.BooleanField(default=True) - event_date: datetime.date = types.DateField() - event_time: datetime.time = types.TimeField() - event_datetime: datetime.datetime = types.DateTimeField() - duration: datetime.timedelta = types.DurationField() - external_id: uuid.UUID = types.UUIDField() + note = types.TextField(max_length=200, allow_null=True, required=False) + count = types.IntegerField() + ratio = types.FloatField() + amount = types.DecimalField(max_digits=10, decimal_places=2) + is_active = types.BooleanField(default=True) + event_date = types.DateField() + event_time = types.TimeField() + event_datetime = types.DateTimeField() + duration = types.DurationField() + external_id = types.UUIDField() query: postgres.QuerySet[FormsExample] = postgres.QuerySet() diff --git a/plain-postgres/tests/app/examples/models/indexes.py b/plain-postgres/tests/app/examples/models/indexes.py index 4acb0b8e1c..fba762f3e2 100644 --- a/plain-postgres/tests/app/examples/models/indexes.py +++ b/plain-postgres/tests/app/examples/models/indexes.py @@ -13,7 +13,7 @@ class IndexExample(postgres.Model): never leak into other test files' schemas. """ - name: str = types.TextField(max_length=100) - description: str = types.TextField(max_length=100) + name = types.TextField(max_length=100) + description = types.TextField(max_length=100) query: postgres.QuerySet[IndexExample] = postgres.QuerySet() diff --git a/plain-postgres/tests/app/examples/models/iteration.py b/plain-postgres/tests/app/examples/models/iteration.py index 4f14449f86..e95e695f4e 100644 --- a/plain-postgres/tests/app/examples/models/iteration.py +++ b/plain-postgres/tests/app/examples/models/iteration.py @@ -14,7 +14,7 @@ class IterationExample(postgres.Model): class plumbing). """ - name: str = types.TextField(max_length=100) - tag: str = types.TextField(max_length=100) + name = types.TextField(max_length=100) + tag = types.TextField(max_length=100) query: postgres.QuerySet[IterationExample] = postgres.QuerySet() diff --git a/plain-postgres/tests/app/examples/models/mixins.py b/plain-postgres/tests/app/examples/models/mixins.py index 2e0dc508ad..69842709ac 100644 --- a/plain-postgres/tests/app/examples/models/mixins.py +++ b/plain-postgres/tests/app/examples/models/mixins.py @@ -1,7 +1,5 @@ from __future__ import annotations -from datetime import datetime - from plain import postgres from plain.postgres import types @@ -9,15 +7,15 @@ class TimestampMixin: """Mixin that provides timestamp fields.""" - created_at: datetime = types.DateTimeField(create_now=True) - updated_at: datetime = types.DateTimeField(update_now=True) + created_at = types.DateTimeField(create_now=True) + updated_at = types.DateTimeField(update_now=True) @postgres.register_model class MixinTestModel(TimestampMixin, postgres.Model): """Model that inherits fields from a mixin.""" - name: str = types.TextField(max_length=100) + name = types.TextField(max_length=100) query: postgres.QuerySet[MixinTestModel] = postgres.QuerySet() diff --git a/plain-postgres/tests/app/examples/models/nullability.py b/plain-postgres/tests/app/examples/models/nullability.py index dea60478b8..f5c1bc3694 100644 --- a/plain-postgres/tests/app/examples/models/nullability.py +++ b/plain-postgres/tests/app/examples/models/nullability.py @@ -12,6 +12,6 @@ class NullabilityExample(postgres.Model): to simulate drift, then verify the SetNotNullFix restores it. """ - required_text: str = types.TextField(max_length=100) + required_text = types.TextField(max_length=100) query: postgres.QuerySet[NullabilityExample] = postgres.QuerySet() diff --git a/plain-postgres/tests/app/examples/models/querysets.py b/plain-postgres/tests/app/examples/models/querysets.py index 9088d682d4..bafdc66925 100644 --- a/plain-postgres/tests/app/examples/models/querysets.py +++ b/plain-postgres/tests/app/examples/models/querysets.py @@ -8,7 +8,7 @@ class DefaultQuerySetModel(postgres.Model): """Model that uses the default objects QuerySet.""" - name: str = types.TextField(max_length=100) + name = types.TextField(max_length=100) query: postgres.QuerySet[DefaultQuerySetModel] = postgres.QuerySet() @@ -27,7 +27,7 @@ def get_custom_qs(self): class CustomQuerySetModel(postgres.Model): """Model with a custom QuerySet.""" - name: str = types.TextField(max_length=100) + name = types.TextField(max_length=100) query = CustomQuerySet() @@ -36,6 +36,6 @@ class CustomQuerySetModel(postgres.Model): class CustomSpecialQuerySetModel(postgres.Model): """Model with a custom special QuerySet.""" - name: str = types.TextField(max_length=100) + name = types.TextField(max_length=100) query = CustomSpecialQuerySet() diff --git a/plain-postgres/tests/app/examples/models/relationships.py b/plain-postgres/tests/app/examples/models/relationships.py index 7a6c942f7a..7bc6bf3003 100644 --- a/plain-postgres/tests/app/examples/models/relationships.py +++ b/plain-postgres/tests/app/examples/models/relationships.py @@ -6,7 +6,7 @@ @postgres.register_model class Tag(postgres.Model): - name: str = types.TextField(max_length=100) + name = types.TextField(max_length=100) query: postgres.QuerySet[Tag] = postgres.QuerySet() @@ -27,8 +27,8 @@ class WidgetTag(postgres.Model): @postgres.register_model class Widget(postgres.Model): - name: str = types.TextField(max_length=100) - size: str = types.TextField(max_length=100) + name = types.TextField(max_length=100) + size = types.TextField(max_length=100) tags: types.ManyToManyManager[Tag] = types.ManyToManyField(Tag, through=WidgetTag) query: postgres.QuerySet[Widget] = postgres.QuerySet() diff --git a/plain-postgres/tests/app/examples/models/storage_parameters.py b/plain-postgres/tests/app/examples/models/storage_parameters.py index c1023476e7..bc5695f745 100644 --- a/plain-postgres/tests/app/examples/models/storage_parameters.py +++ b/plain-postgres/tests/app/examples/models/storage_parameters.py @@ -9,6 +9,6 @@ class StorageParametersExample(postgres.Model): """Dedicated to storage-parameter convergence tests so in-place mutations of `model_options.storage_parameters` don't leak into other suites.""" - name: str = types.TextField(max_length=100) + name = types.TextField(max_length=100) query: postgres.QuerySet[StorageParametersExample] = postgres.QuerySet() diff --git a/plain-postgres/tests/app/examples/models/trees.py b/plain-postgres/tests/app/examples/models/trees.py index a451480a55..863a6b6d16 100644 --- a/plain-postgres/tests/app/examples/models/trees.py +++ b/plain-postgres/tests/app/examples/models/trees.py @@ -8,7 +8,7 @@ class TreeNode(postgres.Model): """Self-referential FK for testing convergence with circular references.""" - name: str = types.TextField(max_length=100) + name = types.TextField(max_length=100) parent: TreeNode | None = types.ForeignKeyField( "self", on_delete=postgres.CASCADE, allow_null=True ) diff --git a/plain-postgres/tests/internal/test_autodetector_not_null_errors.py b/plain-postgres/tests/internal/test_autodetector_not_null_errors.py index 5a27111e95..e3ec7f858b 100644 --- a/plain-postgres/tests/internal/test_autodetector_not_null_errors.py +++ b/plain-postgres/tests/internal/test_autodetector_not_null_errors.py @@ -19,12 +19,12 @@ def test_add_not_null_field_without_default_raises() -> None: from_model = ModelState( package_label="examples", name="Thing", - fields=[("name", types.TextField(max_length=100))], # ty: ignore[invalid-argument-type] + fields=[("name", types.TextField(max_length=100))], ) to_model = ModelState( package_label="examples", name="Thing", - fields=[ # ty: ignore[invalid-argument-type] + fields=[ ("name", types.TextField(max_length=100)), ("status", types.TextField(max_length=50)), ], @@ -41,12 +41,12 @@ def test_add_not_null_field_with_default_succeeds() -> None: from_model = ModelState( package_label="examples", name="Thing", - fields=[("name", types.TextField(max_length=100))], # ty: ignore[invalid-argument-type] + fields=[("name", types.TextField(max_length=100))], ) to_model = ModelState( package_label="examples", name="Thing", - fields=[ # ty: ignore[invalid-argument-type] + fields=[ ("name", types.TextField(max_length=100)), ("status", types.TextField(max_length=50, default="active")), ], @@ -66,12 +66,12 @@ def test_add_nullable_field_without_default_succeeds() -> None: from_model = ModelState( package_label="examples", name="Thing", - fields=[("name", types.TextField(max_length=100))], # ty: ignore[invalid-argument-type] + fields=[("name", types.TextField(max_length=100))], ) to_model = ModelState( package_label="examples", name="Thing", - fields=[ # ty: ignore[invalid-argument-type] + fields=[ ("name", types.TextField(max_length=100)), ( "status", @@ -90,7 +90,7 @@ def test_create_model_with_not_null_field_no_default_succeeds() -> None: to_model = ModelState( package_label="examples", name="Thing", - fields=[ # ty: ignore[invalid-argument-type] + fields=[ ("id", types.PrimaryKeyField()), ("status", types.TextField(max_length=50)), ], @@ -112,7 +112,7 @@ def test_alter_nullable_to_not_null_is_autodetector_no_op() -> None: from_model = ModelState( package_label="examples", name="Thing", - fields=[ # ty: ignore[invalid-argument-type] + fields=[ ( "status", types.TextField(max_length=50, allow_null=True, required=False), @@ -122,7 +122,7 @@ def test_alter_nullable_to_not_null_is_autodetector_no_op() -> None: to_model = ModelState( package_label="examples", name="Thing", - fields=[("status", types.TextField(max_length=50))], # ty: ignore[invalid-argument-type] + fields=[("status", types.TextField(max_length=50))], ) autodetector = MigrationAutodetector(_state_with(from_model), _state_with(to_model)) assert autodetector._detect_changes() == {} @@ -135,7 +135,7 @@ def test_alter_nullable_to_not_null_with_default_is_autodetector_no_op() -> None from_model = ModelState( package_label="examples", name="Thing", - fields=[ # ty: ignore[invalid-argument-type] + fields=[ ( "status", types.TextField(max_length=50, allow_null=True, required=False), @@ -145,7 +145,7 @@ def test_alter_nullable_to_not_null_with_default_is_autodetector_no_op() -> None to_model = ModelState( package_label="examples", name="Thing", - fields=[("status", types.TextField(max_length=50, default="active"))], # ty: ignore[invalid-argument-type] + fields=[("status", types.TextField(max_length=50, default="active"))], ) autodetector = MigrationAutodetector(_state_with(from_model), _state_with(to_model)) assert autodetector._detect_changes() == {} @@ -160,7 +160,7 @@ def test_rename_combined_with_null_change_raises() -> None: from_model = ModelState( package_label="examples", name="Thing", - fields=[ # ty: ignore[invalid-argument-type] + fields=[ ( "old_name", types.TextField(max_length=50, allow_null=True, required=False), @@ -170,7 +170,7 @@ def test_rename_combined_with_null_change_raises() -> None: to_model = ModelState( package_label="examples", name="Thing", - fields=[("new_name", types.TextField(max_length=50))], # ty: ignore[invalid-argument-type] + fields=[("new_name", types.TextField(max_length=50))], ) questioner = MigrationQuestioner(defaults={"ask_rename": True}) autodetector = MigrationAutodetector( @@ -192,12 +192,12 @@ def test_multiple_new_fields_without_default_reports_first() -> None: from_model = ModelState( package_label="examples", name="Thing", - fields=[("name", types.TextField(max_length=100))], # ty: ignore[invalid-argument-type] + fields=[("name", types.TextField(max_length=100))], ) to_model = ModelState( package_label="examples", name="Thing", - fields=[ # ty: ignore[invalid-argument-type] + fields=[ ("name", types.TextField(max_length=100)), ("a_status", types.TextField(max_length=50)), ("z_status", types.TextField(max_length=50)), diff --git a/plain-postgres/tests/internal/test_autodetector_type_change.py b/plain-postgres/tests/internal/test_autodetector_type_change.py index eb161c2cfe..f576d5b0c3 100644 --- a/plain-postgres/tests/internal/test_autodetector_type_change.py +++ b/plain-postgres/tests/internal/test_autodetector_type_change.py @@ -19,12 +19,12 @@ def test_base_type_change_raises() -> None: from_model = ModelState( package_label="examples", name="Thing", - fields=[("created_at", types.DateTimeField())], # ty: ignore[invalid-argument-type] + fields=[("created_at", types.DateTimeField())], ) to_model = ModelState( package_label="examples", name="Thing", - fields=[("created_at", types.UUIDField())], # ty: ignore[invalid-argument-type] + fields=[("created_at", types.UUIDField())], ) autodetector = MigrationAutodetector(_state_with(from_model), _state_with(to_model)) with pytest.raises(MigrationSchemaError) as exc: @@ -40,12 +40,12 @@ def test_parameter_only_change_succeeds() -> None: from_model = ModelState( package_label="examples", name="Thing", - fields=[("name", types.TextField(max_length=50))], # ty: ignore[invalid-argument-type] + fields=[("name", types.TextField(max_length=50))], ) to_model = ModelState( package_label="examples", name="Thing", - fields=[("name", types.TextField(max_length=100))], # ty: ignore[invalid-argument-type] + fields=[("name", types.TextField(max_length=100))], ) autodetector = MigrationAutodetector(_state_with(from_model), _state_with(to_model)) changes = autodetector._detect_changes() @@ -89,12 +89,12 @@ def test_bigint_to_integer_rejected() -> None: from_model = ModelState( package_label="examples", name="Thing", - fields=[("count", types.BigIntegerField())], # ty: ignore[invalid-argument-type] + fields=[("count", types.BigIntegerField())], ) to_model = ModelState( package_label="examples", name="Thing", - fields=[("count", types.IntegerField())], # ty: ignore[invalid-argument-type] + fields=[("count", types.IntegerField())], ) autodetector = MigrationAutodetector(_state_with(from_model), _state_with(to_model)) with pytest.raises(MigrationSchemaError) as exc: diff --git a/plain-postgres/tests/public/test_typed_where.py b/plain-postgres/tests/public/test_typed_where.py new file mode 100644 index 0000000000..904727d004 --- /dev/null +++ b/plain-postgres/tests/public/test_typed_where.py @@ -0,0 +1,110 @@ +"""Typed `where()` clause backed by field-method conditions. + +First slice of the typed query API: field descriptors expose `equals`, +`not_equal`, comparison and string lookup methods that return Q objects; +`QuerySet.where()` accepts them positionally. +""" + +from __future__ import annotations + +from typing import assert_type + +from app.examples.models.defaults import DefaultsExample + +from plain.postgres.fields.numeric import IntegerField +from plain.postgres.fields.text import TextField +from plain.postgres.query_utils import Q + + +def test_class_access_yields_typed_descriptors() -> None: + """Class-level field access returns the descriptor, parameterized by T. + + These `assert_type` calls are checked by the type checker, not at runtime + — but the function still has to import cleanly. + """ + assert_type(DefaultsExample.name, TextField[str]) + assert_type(DefaultsExample.note, TextField[str | None]) + assert_type(DefaultsExample.priority, IntegerField[int]) + + +def test_instance_access_yields_value_type() -> None: + """Instance access returns the value type T (with nullability preserved).""" + row = DefaultsExample(name="x", note=None, priority=1) + assert_type(row.name, str) + assert_type(row.note, str | None) + assert_type(row.priority, int) + + +def test_field_methods_return_q_objects(): + """The methods are usable before any DB hit and produce Q objects.""" + assert isinstance(DefaultsExample.name.equals("foo"), Q) + assert isinstance(DefaultsExample.priority.gte(5), Q) + assert isinstance(DefaultsExample.name.contains("oo"), Q) + assert isinstance(DefaultsExample.note.is_null(), Q) + + +def test_where_filters_by_equals(db): + DefaultsExample.query.create(name="alice") + DefaultsExample.query.create(name="bob") + + rows = list(DefaultsExample.query.where(DefaultsExample.name.equals("alice"))) + assert [r.name for r in rows] == ["alice"] + + +def test_where_ands_multiple_conditions(db): + DefaultsExample.query.create(name="alice", priority=1) + DefaultsExample.query.create(name="alice", priority=10) + DefaultsExample.query.create(name="bob", priority=10) + + rows = list( + DefaultsExample.query.where( + DefaultsExample.name.equals("alice"), + DefaultsExample.priority.gte(5), + ) + ) + assert [(r.name, r.priority) for r in rows] == [("alice", 10)] + + +def test_where_combines_with_or(db): + DefaultsExample.query.create(name="alice") + DefaultsExample.query.create(name="bob") + DefaultsExample.query.create(name="carol") + + rows = list( + DefaultsExample.query.where( + DefaultsExample.name.equals("alice") | DefaultsExample.name.equals("carol") + ).order_by("name") + ) + assert [r.name for r in rows] == ["alice", "carol"] + + +def test_not_equal_filters_inverse(db): + DefaultsExample.query.create(name="alice") + DefaultsExample.query.create(name="bob") + + rows = list(DefaultsExample.query.where(DefaultsExample.name.not_equal("alice"))) + assert [r.name for r in rows] == ["bob"] + + +def test_text_field_string_lookups(db): + DefaultsExample.query.create(name="alice") + DefaultsExample.query.create(name="alpha") + DefaultsExample.query.create(name="bob") + + starts = list( + DefaultsExample.query.where(DefaultsExample.name.startswith("al")).order_by( + "name" + ) + ) + assert [r.name for r in starts] == ["alice", "alpha"] + + +def test_is_null_with_explicit_default(db): + DefaultsExample.query.create(name="alice", note=None) + DefaultsExample.query.create(name="bob") # default "auto" + + nulls = list(DefaultsExample.query.where(DefaultsExample.note.is_null())) + assert [r.name for r in nulls] == ["alice"] + + non_nulls = list(DefaultsExample.query.where(DefaultsExample.note.is_null(False))) + assert [r.name for r in non_nulls] == ["bob"] diff --git a/plain-redirection/plain/redirection/models.py b/plain-redirection/plain/redirection/models.py index 2c622a4546..61ad863ece 100644 --- a/plain-redirection/plain/redirection/models.py +++ b/plain-redirection/plain/redirection/models.py @@ -1,7 +1,6 @@ from __future__ import annotations import re -from datetime import datetime from typing import TYPE_CHECKING from plain import postgres @@ -15,16 +14,16 @@ @postgres.register_model class Redirect(postgres.Model): - from_pattern: str = types.TextField(max_length=255) - to_pattern: str = types.TextField(max_length=255) - http_status: int = types.SmallIntegerField( + from_pattern = types.TextField(max_length=255) + to_pattern = types.TextField(max_length=255) + http_status = types.SmallIntegerField( default=301 ) # Default to permanent - could be choices? - created_at: datetime = types.DateTimeField(create_now=True) - updated_at: datetime = types.DateTimeField(create_now=True, update_now=True) - order: int = types.SmallIntegerField(default=0) - enabled: bool = types.BooleanField(default=True) - is_regex: bool = types.BooleanField(default=False) + created_at = types.DateTimeField(create_now=True) + updated_at = types.DateTimeField(create_now=True, update_now=True) + order = types.SmallIntegerField(default=0) + enabled = types.BooleanField(default=True) + is_regex = types.BooleanField(default=False) # query params? # logged in or not? auth not required necessarily... @@ -97,16 +96,16 @@ class RedirectLog(postgres.Model): redirect: Redirect = types.ForeignKeyField(Redirect, on_delete=postgres.CASCADE) # The actuals that were used to redirect - from_url: str = types.URLField(max_length=512) - to_url: str = types.URLField(max_length=512) - http_status: int = types.SmallIntegerField(default=301) + from_url = types.URLField(max_length=512) + to_url = types.URLField(max_length=512) + http_status = types.SmallIntegerField(default=301) # Request metadata - ip_address: str = types.GenericIPAddressField() - user_agent: str = types.TextField(required=False, max_length=512) - referrer: str = types.TextField(required=False, max_length=512) + ip_address = types.GenericIPAddressField() + user_agent = types.TextField(required=False, max_length=512) + referrer = types.TextField(required=False, max_length=512) - created_at: datetime = types.DateTimeField(create_now=True) + created_at = types.DateTimeField(create_now=True) query: postgres.QuerySet[RedirectLog] = postgres.QuerySet() @@ -154,14 +153,14 @@ def from_redirect(cls, redirect: Redirect, request: Request) -> RedirectLog: @postgres.register_model class NotFoundLog(postgres.Model): - url: str = types.URLField(max_length=512) + url = types.URLField(max_length=512) # Request metadata - ip_address: str = types.GenericIPAddressField() - user_agent: str = types.TextField(required=False, max_length=512) - referrer: str = types.TextField(required=False, max_length=512) + ip_address = types.GenericIPAddressField() + user_agent = types.TextField(required=False, max_length=512) + referrer = types.TextField(required=False, max_length=512) - created_at: datetime = types.DateTimeField(create_now=True) + created_at = types.DateTimeField(create_now=True) query: postgres.QuerySet[NotFoundLog] = postgres.QuerySet() diff --git a/plain-sessions/plain/sessions/models.py b/plain-sessions/plain/sessions/models.py index 6885508d4e..b3f1e5147b 100644 --- a/plain-sessions/plain/sessions/models.py +++ b/plain-sessions/plain/sessions/models.py @@ -1,7 +1,5 @@ from __future__ import annotations -from datetime import datetime - from plain import postgres from plain.postgres import types @@ -10,10 +8,10 @@ @postgres.register_model class Session(postgres.Model): - session_key: str = types.TextField(max_length=40) - session_data: dict = types.JSONField(default={}, required=False) - created_at: datetime = types.DateTimeField(create_now=True) - expires_at: datetime | None = types.DateTimeField(allow_null=True) + session_key = types.TextField(max_length=40) + session_data = types.JSONField(default={}, required=False) + created_at = types.DateTimeField(create_now=True) + expires_at = types.DateTimeField(allow_null=True) query: postgres.QuerySet[Session] = postgres.QuerySet() From 1f02cf011ccaff70547896dfe3f9eac3b245fe8a Mon Sep 17 00:00:00 2001 From: Dave Gaeddert Date: Sat, 23 May 2026 13:29:03 -0500 Subject: [PATCH 02/21] Block typed-query comparisons on encrypted fields Override equals/not_equal/gt/gte/lt/lte on EncryptedFieldMixin with a `Never`-typed parameter so a type checker rejects the call at the use site, and raise TypeError at runtime as a safety net. is_null remains the only meaningful comparison since ciphertext is non-deterministic. This makes the design's "method surface = capability" promise actually load-bearing: misuse is a type error, not a runtime no-op or a silently empty filter. --- .../plain/postgres/fields/encrypted.py | 31 ++++++++++++++++++- .../tests/public/test_encrypted_fields.py | 28 +++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/plain-postgres/plain/postgres/fields/encrypted.py b/plain-postgres/plain/postgres/fields/encrypted.py index 6d45a0509b..f3def79031 100644 --- a/plain-postgres/plain/postgres/fields/encrypted.py +++ b/plain-postgres/plain/postgres/fields/encrypted.py @@ -3,7 +3,7 @@ import base64 import json from functools import cache -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Never try: from cryptography.fernet import Fernet, InvalidToken, MultiFernet @@ -27,6 +27,7 @@ from plain.postgres.connection import DatabaseConnection from plain.postgres.lookups import Lookup, Transform + from plain.postgres.query_utils import Q from plain.preflight.results import PreflightResult __all__ = [ @@ -136,6 +137,34 @@ def get_transform( ) -> type[Transform] | Callable[..., Any] | None: return None + # Block typed-query comparison methods. Ciphertext is non-deterministic, + # so equality/ordering against a Python value can't match anything + # meaningful. The parameter type is `Never` so a type checker rejects any + # call site, and the runtime raises if someone bypasses the type checker. + def equals(self, value: Never) -> Q: + raise TypeError(self._lookup_unsupported_message("equals")) + + def not_equal(self, value: Never) -> Q: + raise TypeError(self._lookup_unsupported_message("not_equal")) + + def gt(self, value: Never) -> Q: + raise TypeError(self._lookup_unsupported_message("gt")) + + def gte(self, value: Never) -> Q: + raise TypeError(self._lookup_unsupported_message("gte")) + + def lt(self, value: Never) -> Q: + raise TypeError(self._lookup_unsupported_message("lt")) + + def lte(self, value: Never) -> Q: + raise TypeError(self._lookup_unsupported_message("lte")) + + def _lookup_unsupported_message(self, method: str) -> str: + return ( + f"Encrypted field {self.name!r} does not support .{method}() — " + "ciphertext is non-deterministic. Use .is_null() instead." + ) + def _check_encrypted_constraints(self) -> list[PreflightResult]: errors: list[PreflightResult] = [] if not hasattr(self, "model"): diff --git a/plain-postgres/tests/public/test_encrypted_fields.py b/plain-postgres/tests/public/test_encrypted_fields.py index decc080854..a84b70f51f 100644 --- a/plain-postgres/tests/public/test_encrypted_fields.py +++ b/plain-postgres/tests/public/test_encrypted_fields.py @@ -144,6 +144,34 @@ def test_transform_blocked(self, db): assert field.get_transform("lower") is None # ty: ignore[unresolved-attribute] +class TestTypedQueryMethodsBlocked: + """Encrypted fields must not expose typed-query comparison methods. + + The class-level overrides accept `Never`, so type checkers reject any + call site. The runtime also raises TypeError as a safety net for callers + that bypass type checking (e.g. dynamic code). + """ + + def test_equals_raises(self): + with pytest.raises(TypeError, match=r"api_key.*does not support \.equals\("): + SecretStore.api_key.equals("anything") # ty: ignore[invalid-argument-type] + + def test_not_equal_raises(self): + with pytest.raises(TypeError, match=r"does not support \.not_equal\("): + SecretStore.api_key.not_equal("x") # ty: ignore[invalid-argument-type] + + def test_ordering_comparisons_raise(self): + for method in ("gt", "gte", "lt", "lte"): + with pytest.raises(TypeError, match=rf"does not support \.{method}\("): + getattr(SecretStore.api_key, method)("x") + + def test_is_null_still_works(self): + """is_null is the one comparison that makes sense on ciphertext.""" + from plain.postgres.query_utils import Q + + assert isinstance(SecretStore.api_key.is_null(), Q) + + class TestKeyRotation: def test_decrypt_with_fallback_key(self): """Data encrypted with an old key should decrypt when that key is in fallbacks.""" From 0fc51f299900fbd37893dc17c49390caa9e87e25 Mon Sep 17 00:00:00 2001 From: Dave Gaeddert Date: Sun, 24 May 2026 21:45:37 -0500 Subject: [PATCH 03/21] Close kwarg-path foot-gun and tighten encrypted-field block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review findings on the previous commit: 1. The typed `.equals()` block doesn't help users on the legacy kwarg path. `filter(api_key='x')` still resolved via the allowed `exact` lookup and silently returned zero rows (ciphertext is non- deterministic). Wrap the exact lookup class in `get_lookup()` so non-None right-hand values raise TypeError at lookup construction. None still passes through, preserving the exact-None → isnull rewrite. 2. Strengthen `is_null` test to inspect the Q's children instead of just asserting isinstance — a regression in `Field.is_null` would have slipped past the old check. 3. Add `assert self.name is not None` in the error-message helper, so a call on an unbound field fails loudly instead of rendering "None" in the error. 4. Change override return type from `Q` to `Never` to match the actually-unreachable return; `Never` is assignable to `Q` so call sites like `where(field.equals(...))` still type-check at the use site, with the `Never` parameter error as the surfaced diagnostic. 5. Parametrize the ordering-comparison test so each method (gt/gte/lt/ lte) reports independently. --- .../plain/postgres/fields/encrypted.py | 60 +++++++++++++++---- .../tests/public/test_encrypted_fields.py | 44 ++++++++++++-- 2 files changed, 85 insertions(+), 19 deletions(-) diff --git a/plain-postgres/plain/postgres/fields/encrypted.py b/plain-postgres/plain/postgres/fields/encrypted.py index f3def79031..ca2b966e9c 100644 --- a/plain-postgres/plain/postgres/fields/encrypted.py +++ b/plain-postgres/plain/postgres/fields/encrypted.py @@ -27,7 +27,6 @@ from plain.postgres.connection import DatabaseConnection from plain.postgres.lookups import Lookup, Transform - from plain.postgres.query_utils import Q from plain.preflight.results import PreflightResult __all__ = [ @@ -107,16 +106,40 @@ def _decrypt(value: str) -> str: # isnull is obviously needed. exact is required so that `filter(field=None)` # works — the ORM resolves "exact" first and then rewrites None to isnull. -# Exact lookups on non-None values will silently return no results (since -# ciphertext is non-deterministic), but blocking exact entirely would break -# the None/isnull path. +# get_lookup() below wraps the exact lookup class to reject non-None right-hand +# values at lookup construction time, so the silent-no-rows behavior on +# `filter(field='something')` is also blocked. _ALLOWED_LOOKUPS = {"isnull", "exact"} +@cache +def _exact_for_encrypted(base: type[Lookup]) -> type[Lookup]: + """Return a subclass of `base` (the Exact lookup) that rejects non-None + right-hand values. None passes through so the ORM's exact-None → isnull + rewrite in `build_lookup` still works. + """ + + class _EncryptedExact(base): # ty: ignore[unsupported-base] + def __init__(self, lhs: Any, rhs: Any) -> None: + if rhs is not None: + target = getattr(lhs, "target", None) + field_name = getattr(target, "name", None) or "" + raise TypeError( + f"Encrypted field {field_name!r} cannot be filtered by " + "equality against a non-None value — ciphertext is " + "non-deterministic. Use Model.field.is_null() or " + "filter(field__isnull=True) for null checks." + ) + super().__init__(lhs, rhs) + + return _EncryptedExact + + class EncryptedFieldMixin: """Shared behavior for all encrypted fields. - Blocks lookups (except isnull and exact) since encrypted values are non-deterministic. + Blocks lookups (except isnull) since encrypted values are non-deterministic. + Allows exact only for the None-rewrite path; rejects non-None exact rhs. Errors at preflight if the field is used in indexes or unique constraints. Must be used with Field as a co-base class. @@ -130,7 +153,10 @@ def get_lookup(self, lookup_name: str) -> type[Lookup] | None: if lookup_name not in _ALLOWED_LOOKUPS: return None get_lookup = getattr(super(), "get_lookup") - return get_lookup(lookup_name) + base = get_lookup(lookup_name) + if lookup_name == "exact" and base is not None: + return _exact_for_encrypted(base) + return base def get_transform( self, lookup_name: str @@ -140,26 +166,34 @@ def get_transform( # Block typed-query comparison methods. Ciphertext is non-deterministic, # so equality/ordering against a Python value can't match anything # meaningful. The parameter type is `Never` so a type checker rejects any - # call site, and the runtime raises if someone bypasses the type checker. - def equals(self, value: Never) -> Q: + # call site; the runtime raises if someone bypasses the type checker. + # Return type is `Never` (not `Q`) to reflect that control never returns — + # `Never` is assignable to `Q` so `where(field.equals(...))` still + # type-checks at the use site, and the parameter error is the one that + # surfaces. + def equals(self, value: Never) -> Never: raise TypeError(self._lookup_unsupported_message("equals")) - def not_equal(self, value: Never) -> Q: + def not_equal(self, value: Never) -> Never: raise TypeError(self._lookup_unsupported_message("not_equal")) - def gt(self, value: Never) -> Q: + def gt(self, value: Never) -> Never: raise TypeError(self._lookup_unsupported_message("gt")) - def gte(self, value: Never) -> Q: + def gte(self, value: Never) -> Never: raise TypeError(self._lookup_unsupported_message("gte")) - def lt(self, value: Never) -> Q: + def lt(self, value: Never) -> Never: raise TypeError(self._lookup_unsupported_message("lt")) - def lte(self, value: Never) -> Q: + def lte(self, value: Never) -> Never: raise TypeError(self._lookup_unsupported_message("lte")) def _lookup_unsupported_message(self, method: str) -> str: + assert self.name is not None, ( + "Encrypted field must be attached to a model before its typed-query " + "methods can produce a meaningful error message." + ) return ( f"Encrypted field {self.name!r} does not support .{method}() — " "ciphertext is non-deterministic. Use .is_null() instead." diff --git a/plain-postgres/tests/public/test_encrypted_fields.py b/plain-postgres/tests/public/test_encrypted_fields.py index a84b70f51f..0d85c7d029 100644 --- a/plain-postgres/tests/public/test_encrypted_fields.py +++ b/plain-postgres/tests/public/test_encrypted_fields.py @@ -160,16 +160,48 @@ def test_not_equal_raises(self): with pytest.raises(TypeError, match=r"does not support \.not_equal\("): SecretStore.api_key.not_equal("x") # ty: ignore[invalid-argument-type] - def test_ordering_comparisons_raise(self): - for method in ("gt", "gte", "lt", "lte"): - with pytest.raises(TypeError, match=rf"does not support \.{method}\("): - getattr(SecretStore.api_key, method)("x") + @pytest.mark.parametrize("method", ["gt", "gte", "lt", "lte"]) + def test_ordering_comparison_raises(self, method): + with pytest.raises(TypeError, match=rf"does not support \.{method}\("): + getattr(SecretStore.api_key, method)("x") - def test_is_null_still_works(self): + def test_is_null_returns_correct_lookup(self): """is_null is the one comparison that makes sense on ciphertext.""" from plain.postgres.query_utils import Q - assert isinstance(SecretStore.api_key.is_null(), Q) + q = SecretStore.api_key.is_null() + assert isinstance(q, Q) + assert q.children == [("api_key__isnull", True)] + + q_false = SecretStore.api_key.is_null(False) + assert q_false.children == [("api_key__isnull", False)] + + +class TestKwargFilterBlocked: + """Block the legacy kwarg/Q path the same way the typed methods are + blocked: `filter(api_key='x')` on an encrypted field would silently + return zero rows because ciphertext is non-deterministic. + `filter(api_key=None)` is preserved so it still rewrites to IS NULL. + """ + + def test_filter_non_none_raises(self, db): + with pytest.raises( + TypeError, + match=r"api_key.*cannot be filtered by equality against a non-None value", + ): + SecretStore.query.filter(api_key="sk-test").count() + + def test_exclude_non_none_raises(self, db): + with pytest.raises( + TypeError, + match=r"api_key.*cannot be filtered by equality against a non-None value", + ): + SecretStore.query.exclude(api_key="sk-test").count() + + def test_filter_none_still_rewrites_to_isnull(self, db): + """filter(field=None) must continue to work — ORM rewrites to isnull.""" + SecretStore.query.create(name="test", api_key="sk-test", config=None) + assert SecretStore.query.filter(config=None).count() == 1 class TestKeyRotation: From f76757cdf032c0295392517d94a2135df1eb9c24 Mon Sep 17 00:00:00 2001 From: Dave Gaeddert Date: Sun, 24 May 2026 22:39:02 -0500 Subject: [PATCH 04/21] Add typed FK traversal for where() clauses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the typed read API across forward foreign keys: Child.parent.name.equals("foo") → Q(parent__name="foo") Order.user.profile.city.equals(...) → Q(user__profile__city=...) Runtime: new RelatedFieldRef / PrefixedFieldRef helpers proxy class-level attribute access through to the related model's fields, accumulating a lookup-path prefix as it goes. ForwardForeignKeyDescriptor gets a __getattr__ that yields the initial RelatedFieldRef so chaining starts from `Child.parent`. The SQL builder's existing names_to_path / join machinery handles the rest — we just produce correctly-prefixed Q. Typing: ForeignKeyField stub now returns a _ForeignKeyDescriptor[T, V] with overloaded __get__ — class access yields `type[T]` (so the related model's typed field surface is visible) and instance access yields V (T or T | None). __set__ is overloaded to accept V | int so bare PK assignment still type-checks. Migration: drops `: ModelType = types.ForeignKeyField(...)` annotations across the monorepo. The new stub provides the descriptor type and the overloads handle both class- and instance-side typing. Scope: forward FK only. Reverse FK (ReverseForeignKey) and M2M traversal will follow the same pattern in a separate commit. --- example/app/notes/models.py | 2 +- example/app/tasks/models.py | 12 +- plain-flags/plain/flags/models.py | 2 +- plain-observer/plain/observer/models.py | 6 +- .../postgres/fields/related_descriptors.py | 26 ++++ .../plain/postgres/fields/related_typed.py | 133 ++++++++++++++++++ plain-postgres/plain/postgres/types.pyi | 21 ++- .../tests/app/examples/models/delete.py | 34 ++--- .../app/examples/models/relationships.py | 4 +- .../tests/app/examples/models/trees.py | 4 +- .../internal/test_fk_characterization.py | 2 +- .../tests/public/test_delete_behaviors.py | 1 + plain-postgres/tests/public/test_related.py | 4 +- .../tests/public/test_typed_where_fk.py | 93 ++++++++++++ plain-redirection/plain/redirection/models.py | 2 +- 15 files changed, 301 insertions(+), 45 deletions(-) create mode 100644 plain-postgres/plain/postgres/fields/related_typed.py create mode 100644 plain-postgres/tests/public/test_typed_where_fk.py diff --git a/example/app/notes/models.py b/example/app/notes/models.py index 30f3518c0b..381553c36b 100644 --- a/example/app/notes/models.py +++ b/example/app/notes/models.py @@ -8,7 +8,7 @@ @postgres.register_model class Note(postgres.Model): - author: User = types.ForeignKeyField( + author = types.ForeignKeyField( User, on_delete=postgres.CASCADE, related_query_name="notes", diff --git a/example/app/tasks/models.py b/example/app/tasks/models.py index 845612077f..b6f8e1158c 100644 --- a/example/app/tasks/models.py +++ b/example/app/tasks/models.py @@ -15,7 +15,7 @@ @postgres.register_model class Project(postgres.Model): - owner: User = types.ForeignKeyField( + owner = types.ForeignKeyField( User, on_delete=postgres.CASCADE, related_query_name="projects", @@ -40,7 +40,7 @@ def __str__(self) -> str: @postgres.register_model class Tag(postgres.Model): - owner: User = types.ForeignKeyField( + owner = types.ForeignKeyField( User, on_delete=postgres.CASCADE, related_query_name="tags", @@ -66,9 +66,9 @@ def __str__(self) -> str: class TaskTag(postgres.Model): """Through model for Task ↔ Tag M2M.""" - task: Task = types.ForeignKeyField("Task", on_delete=postgres.CASCADE) + task = types.ForeignKeyField("Task", on_delete=postgres.CASCADE) task_id: int - tag: Tag = types.ForeignKeyField(Tag, on_delete=postgres.CASCADE) + tag = types.ForeignKeyField(Tag, on_delete=postgres.CASCADE) tag_id: int query: postgres.QuerySet[TaskTag] = postgres.QuerySet() @@ -84,12 +84,12 @@ class TaskTag(postgres.Model): @postgres.register_model class Task(postgres.Model): - owner: User = types.ForeignKeyField( + owner = types.ForeignKeyField( User, on_delete=postgres.CASCADE, related_query_name="tasks", ) - project: Project | None = types.ForeignKeyField( + project = types.ForeignKeyField( Project, on_delete=postgres.SET_NULL, related_query_name="tasks", diff --git a/plain-flags/plain/flags/models.py b/plain-flags/plain/flags/models.py index 5a5b26289b..4200d79422 100644 --- a/plain-flags/plain/flags/models.py +++ b/plain-flags/plain/flags/models.py @@ -18,7 +18,7 @@ def validate_flag_name(value: str) -> None: class FlagResult(postgres.Model): created_at = types.DateTimeField(create_now=True) updated_at = types.DateTimeField(create_now=True, update_now=True) - flag: Flag = types.ForeignKeyField("Flag", on_delete=postgres.CASCADE) + flag = types.ForeignKeyField("Flag", on_delete=postgres.CASCADE) key = types.TextField(max_length=255) value = types.JSONField() diff --git a/plain-observer/plain/observer/models.py b/plain-observer/plain/observer/models.py index 2b2e6dc3e7..120c165b82 100644 --- a/plain-observer/plain/observer/models.py +++ b/plain-observer/plain/observer/models.py @@ -328,7 +328,7 @@ def annotate_spans(self) -> list[Span]: @postgres.register_model class Span(postgres.Model): - trace: Trace = types.ForeignKeyField(Trace, on_delete=postgres.CASCADE) + trace = types.ForeignKeyField(Trace, on_delete=postgres.CASCADE) span_id = types.TextField(max_length=255) @@ -506,8 +506,8 @@ def get_exception_stacktrace(self) -> str | None: @postgres.register_model class Log(postgres.Model): - trace: Trace = types.ForeignKeyField(Trace, on_delete=postgres.CASCADE) - span: Span | None = types.ForeignKeyField( + trace = types.ForeignKeyField(Trace, on_delete=postgres.CASCADE) + span = types.ForeignKeyField( Span, on_delete=postgres.SET_NULL, allow_null=True, diff --git a/plain-postgres/plain/postgres/fields/related_descriptors.py b/plain-postgres/plain/postgres/fields/related_descriptors.py index 80c5d48921..837dedd0f7 100644 --- a/plain-postgres/plain/postgres/fields/related_descriptors.py +++ b/plain-postgres/plain/postgres/fields/related_descriptors.py @@ -119,6 +119,32 @@ def get_prefetch_queryset( False, ) + def __getattr__(self, name: str) -> Any: + """Proxy class-level attribute access to the related model so typed + where() can traverse the relation: + + Child.parent.name.equals("x") → Q(parent__name="x") + + Only triggers for attributes not found on the descriptor itself. + Returns AttributeError for dunders / private names so pickling, + copy.deepcopy, and hasattr() probes fail cleanly. + """ + if name.startswith("_"): + raise AttributeError(name) + from plain.postgres.fields.related_typed import RelatedFieldRef + + remote_model = self.field.remote_field.model + if isinstance(remote_model, str): + # Relation not yet resolved (still a lazy string ref). Fail + # loudly rather than silently producing wrong-shaped queries. + raise AttributeError( + f"Cannot traverse {self.field.name!r}: related model has " + "not been registered yet." + ) + return getattr( + RelatedFieldRef(model=remote_model, prefix=self.field.name), name + ) + def __get__( self, instance: Any | None, cls: type | None = None ) -> ForwardForeignKeyDescriptor | Any | None: diff --git a/plain-postgres/plain/postgres/fields/related_typed.py b/plain-postgres/plain/postgres/fields/related_typed.py new file mode 100644 index 0000000000..a066d31392 --- /dev/null +++ b/plain-postgres/plain/postgres/fields/related_typed.py @@ -0,0 +1,133 @@ +"""Typed FK traversal for the where() query API. + +When `Order.user` is a ForeignKey, accessing `.email` at the class level (as in +`where(Order.user.email.equals("x"))`) needs to produce +`Q(user__email="x")` so the existing SQL builder's join machinery resolves the +right column. The descriptor doesn't expose the related model's fields directly, +so we proxy attribute access through these two helpers. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from plain.postgres.query_utils import Q + +if TYPE_CHECKING: + from plain.postgres.base import Model + + +class RelatedFieldRef: + """Class-level proxy that walks attribute access into the related model + and accumulates the lookup path prefix as it goes. + + Yielded by `ForwardForeignKeyDescriptor.__getattr__` for the first hop; + chained traversal (`Order.user.profile.city`) builds nested + `RelatedFieldRef` instances until a concrete field is reached. + """ + + def __init__(self, model: type[Model], prefix: str) -> None: + self._model = model + self._prefix = prefix + + def __repr__(self) -> str: + return f"" + + def __getattr__(self, name: str) -> Any: + if name.startswith("_"): + # Avoid infinite recursion on internals and let pickling/hasattr + # checks fail cleanly. + raise AttributeError(name) + from plain.postgres.fields.base import Field + from plain.postgres.fields.related_descriptors import ( + ForwardForeignKeyDescriptor, + ) + + try: + attr = self._model.__dict__[name] + except KeyError: + # Fall back to a full lookup so inherited fields resolve. + attr = getattr(self._model, name, None) + if attr is None: + raise AttributeError(name) from None + + next_prefix = f"{self._prefix}__{name}" + if isinstance(attr, Field): + return PrefixedFieldRef(field=attr, prefix=next_prefix) + if isinstance(attr, ForwardForeignKeyDescriptor): + remote_model = attr.field.remote_field.model + if isinstance(remote_model, str): + raise AttributeError( + f"Cannot traverse {self._prefix}.{name}: relation is " + "still a string reference; the related model has not " + "been registered yet." + ) + return RelatedFieldRef(model=remote_model, prefix=next_prefix) + raise AttributeError( + f"{self._prefix}.{name} is not a traversable field or relation" + ) + + +class PrefixedFieldRef: + """A field-like reference that produces Q objects with a multi-segment + lookup path. Mirrors the typed-query method surface of `Field` and + `TextField` so chained access reads identically to direct access: + + Order.user.email.equals("x") # PrefixedFieldRef("user__email") + Order.email.equals("x") # Field/TextField on Order + """ + + def __init__(self, field: Any, prefix: str) -> None: + self._field = field + self._prefix = prefix + + def __repr__(self) -> str: + return f"" + + # Mirror Field[T] typed-query methods. Lookup suffixes match the strings + # the base Field methods produce via _build_q, so SQL resolution is the + # same as for a direct field reference. + def equals(self, value: Any) -> Q: + return self._q("", value) + + def not_equal(self, value: Any) -> Q: + return ~self._q("", value) + + def gt(self, value: Any) -> Q: + return self._q("gt", value) + + def gte(self, value: Any) -> Q: + return self._q("gte", value) + + def lt(self, value: Any) -> Q: + return self._q("lt", value) + + def lte(self, value: Any) -> Q: + return self._q("lte", value) + + def is_null(self, value: bool = True) -> Q: + return self._q("isnull", value) + + # TextField-specific lookups — always exposed at the proxy layer because + # callers go through the typing lie (`Order.user.email` reads as + # TextField[str] to the type checker). At runtime, calling .contains on + # a non-text field's PrefixedFieldRef would build SQL that errors at + # query time, which is the same failure mode as a manual + # `filter(user__priority__contains=...)`. + def contains(self, value: str) -> Q: + return self._q("contains", value) + + def icontains(self, value: str) -> Q: + return self._q("icontains", value) + + def startswith(self, value: str) -> Q: + return self._q("startswith", value) + + def endswith(self, value: str) -> Q: + return self._q("endswith", value) + + def _q(self, suffix: str, value: Any) -> Q: + key = f"{self._prefix}__{suffix}" if suffix else self._prefix + q = Q() + q.children.append((key, value)) + return q diff --git a/plain-postgres/plain/postgres/types.pyi b/plain-postgres/plain/postgres/types.pyi index 49da755feb..4cff1f499e 100644 --- a/plain-postgres/plain/postgres/types.pyi +++ b/plain-postgres/plain/postgres/types.pyi @@ -436,6 +436,23 @@ def EncryptedJSONField( ) -> Any: ... # Related fields +# +# At the type level, the ForeignKeyField stub returns a descriptor whose +# `__get__` overloads do double duty: +# * Class access (User.parent) → type[T] so the related model's typed +# field surface (e.g. `User.parent.name.equals(...)`) is visible to +# the type checker for typed where() chaining. +# * Instance access (user.parent) → T (or T | None for nullable FKs) so +# reading the loaded related instance has the value type. +# The runtime is a Field instance + a ForwardForeignKeyDescriptor, which +# is structurally compatible — only the typing-side shape differs. +class _ForeignKeyDescriptor[T: Model, V]: + @overload + def __get__(self, instance: None, owner: type) -> type[T]: ... + @overload + def __get__(self, instance: Model, owner: type) -> V: ... + def __set__(self, instance: Model, value: V | int) -> None: ... + @overload def ForeignKeyField[T: Model]( to: type[T] | str, @@ -447,7 +464,7 @@ def ForeignKeyField[T: Model]( required: bool = True, allow_null: Literal[True], validators: Sequence[Callable[..., Any]] = (), -) -> T | None: ... +) -> _ForeignKeyDescriptor[T, T | None]: ... @overload def ForeignKeyField[T: Model]( to: type[T] | str, @@ -459,7 +476,7 @@ def ForeignKeyField[T: Model]( required: bool = True, allow_null: Literal[False] = False, validators: Sequence[Callable[..., Any]] = (), -) -> T: ... +) -> _ForeignKeyDescriptor[T, T]: ... def ManyToManyField[T: Model]( to: type[T] | str, *, diff --git a/plain-postgres/tests/app/examples/models/delete.py b/plain-postgres/tests/app/examples/models/delete.py index 88710ef3eb..c3bd2a5e62 100644 --- a/plain-postgres/tests/app/examples/models/delete.py +++ b/plain-postgres/tests/app/examples/models/delete.py @@ -24,25 +24,21 @@ class DeleteParent(postgres.Model): @postgres.register_model class ChildCascade(postgres.Model): - parent: DeleteParent = types.ForeignKeyField( - DeleteParent, on_delete=postgres.CASCADE - ) + parent = types.ForeignKeyField(DeleteParent, on_delete=postgres.CASCADE) query: postgres.QuerySet[ChildCascade] = postgres.QuerySet() @postgres.register_model class ChildRestrict(postgres.Model): - parent: DeleteParent = types.ForeignKeyField( - DeleteParent, on_delete=postgres.RESTRICT - ) + parent = types.ForeignKeyField(DeleteParent, on_delete=postgres.RESTRICT) query: postgres.QuerySet[ChildRestrict] = postgres.QuerySet() @postgres.register_model class ChildSetNull(postgres.Model): - parent: DeleteParent | None = types.ForeignKeyField( + parent = types.ForeignKeyField( DeleteParent, on_delete=postgres.SET_NULL, allow_null=True, @@ -53,9 +49,7 @@ class ChildSetNull(postgres.Model): @postgres.register_model class ChildNoAction(postgres.Model): - parent: DeleteParent = types.ForeignKeyField( - DeleteParent, on_delete=postgres.NO_ACTION - ) + parent = types.ForeignKeyField(DeleteParent, on_delete=postgres.NO_ACTION) query: postgres.QuerySet[ChildNoAction] = postgres.QuerySet() @@ -64,7 +58,7 @@ class ChildNoAction(postgres.Model): class UnconstrainedChild(postgres.Model): """FK with db_constraint=False — no DB constraint, convergence should ignore.""" - parent: DeleteParent = types.ForeignKeyField( + parent = types.ForeignKeyField( DeleteParent, on_delete=postgres.NO_ACTION, db_constraint=False ) @@ -108,16 +102,14 @@ class Grandparent(postgres.Model): @postgres.register_model class MidParent(postgres.Model): - grandparent: Grandparent = types.ForeignKeyField( - Grandparent, on_delete=postgres.CASCADE - ) + grandparent = types.ForeignKeyField(Grandparent, on_delete=postgres.CASCADE) query: postgres.QuerySet[MidParent] = postgres.QuerySet() @postgres.register_model class Grandchild(postgres.Model): - mid_parent: MidParent = types.ForeignKeyField(MidParent, on_delete=postgres.CASCADE) + mid_parent = types.ForeignKeyField(MidParent, on_delete=postgres.CASCADE) query: postgres.QuerySet[Grandchild] = postgres.QuerySet() @@ -143,12 +135,8 @@ class DiamondParentB(postgres.Model): @postgres.register_model class DiamondChild(postgres.Model): - parent_a: DiamondParentA = types.ForeignKeyField( - DiamondParentA, on_delete=postgres.CASCADE - ) - parent_b: DiamondParentB = types.ForeignKeyField( - DiamondParentB, on_delete=postgres.CASCADE - ) + parent_a = types.ForeignKeyField(DiamondParentA, on_delete=postgres.CASCADE) + parent_b = types.ForeignKeyField(DiamondParentB, on_delete=postgres.CASCADE) query: postgres.QuerySet[DiamondChild] = postgres.QuerySet() @@ -162,7 +150,7 @@ class DiamondChild(postgres.Model): @postgres.register_model class CircA(postgres.Model): name = types.TextField(max_length=100) - partner: CircB | None = types.ForeignKeyField( + partner = types.ForeignKeyField( "CircB", on_delete=postgres.CASCADE, allow_null=True, @@ -174,7 +162,7 @@ class CircA(postgres.Model): @postgres.register_model class CircB(postgres.Model): name = types.TextField(max_length=100) - partner: CircA | None = types.ForeignKeyField( + partner = types.ForeignKeyField( CircA, on_delete=postgres.CASCADE, allow_null=True, diff --git a/plain-postgres/tests/app/examples/models/relationships.py b/plain-postgres/tests/app/examples/models/relationships.py index 7bc6bf3003..985b3bc003 100644 --- a/plain-postgres/tests/app/examples/models/relationships.py +++ b/plain-postgres/tests/app/examples/models/relationships.py @@ -19,8 +19,8 @@ class Tag(postgres.Model): class WidgetTag(postgres.Model): """Through model for Widget-Tag many-to-many relationship.""" - widget: Widget = types.ForeignKeyField("Widget", on_delete=postgres.CASCADE) - tag: Tag = types.ForeignKeyField(Tag, on_delete=postgres.CASCADE) + widget = types.ForeignKeyField("Widget", on_delete=postgres.CASCADE) + tag = types.ForeignKeyField(Tag, on_delete=postgres.CASCADE) query: postgres.QuerySet[WidgetTag] = postgres.QuerySet() diff --git a/plain-postgres/tests/app/examples/models/trees.py b/plain-postgres/tests/app/examples/models/trees.py index 863a6b6d16..e49b4269bd 100644 --- a/plain-postgres/tests/app/examples/models/trees.py +++ b/plain-postgres/tests/app/examples/models/trees.py @@ -9,8 +9,6 @@ class TreeNode(postgres.Model): """Self-referential FK for testing convergence with circular references.""" name = types.TextField(max_length=100) - parent: TreeNode | None = types.ForeignKeyField( - "self", on_delete=postgres.CASCADE, allow_null=True - ) + parent = types.ForeignKeyField("self", on_delete=postgres.CASCADE, allow_null=True) query: postgres.QuerySet[TreeNode] = postgres.QuerySet() diff --git a/plain-postgres/tests/internal/test_fk_characterization.py b/plain-postgres/tests/internal/test_fk_characterization.py index b47cf35e85..a39b57513c 100644 --- a/plain-postgres/tests/internal/test_fk_characterization.py +++ b/plain-postgres/tests/internal/test_fk_characterization.py @@ -120,7 +120,7 @@ def test_assign_bare_int(db): # NOW: a bare primary key value is accepted. parent = DeleteParent.query.create(name="P") child = ChildCascade.query.create(parent=parent) - child.parent = parent.id # ty: ignore[invalid-assignment] + child.parent = parent.id assert child.parent.id == parent.id diff --git a/plain-postgres/tests/public/test_delete_behaviors.py b/plain-postgres/tests/public/test_delete_behaviors.py index 3e986069b6..3bda93da47 100644 --- a/plain-postgres/tests/public/test_delete_behaviors.py +++ b/plain-postgres/tests/public/test_delete_behaviors.py @@ -288,6 +288,7 @@ def test_delete_and_reinsert_replacement_in_one_atomic(db): parent.delete() child.refresh_from_db() + assert child.parent is not None assert child.parent.id == DeleteParent.query.get(name="replacement").id diff --git a/plain-postgres/tests/public/test_related.py b/plain-postgres/tests/public/test_related.py index 49a97298cb..adcf837eb3 100644 --- a/plain-postgres/tests/public/test_related.py +++ b/plain-postgres/tests/public/test_related.py @@ -635,7 +635,7 @@ def test_save_with_deferred_fk_preserves_value(self, db): def test_assign_bool_is_rejected(self, db): child = ChildCascade() with pytest.raises(ValueError, match="Cannot assign"): - child.parent = True # ty: ignore[invalid-assignment] + child.parent = True def test_reassign_by_bare_pk_evicts_cached_object(self, db): # When a cached foreign key is reassigned by bare primary key to a @@ -647,7 +647,7 @@ def test_reassign_by_bare_pk_evicts_cached_object(self, db): # Prime the forward cache so we can prove it gets evicted. assert child.parent.name == "P1" - child.parent = p2.id # ty: ignore[invalid-assignment] + child.parent = p2.id # The cached p1 must be gone -- a fresh read returns the new target. assert child.parent.id == p2.id diff --git a/plain-postgres/tests/public/test_typed_where_fk.py b/plain-postgres/tests/public/test_typed_where_fk.py new file mode 100644 index 0000000000..6ed0b97f3b --- /dev/null +++ b/plain-postgres/tests/public/test_typed_where_fk.py @@ -0,0 +1,93 @@ +"""Typed where() across forward foreign-key relations. + +`ChildCascade.parent` is a ForeignKeyField to DeleteParent. Accessing +`.name` on the class-level descriptor should yield a PrefixedFieldRef +whose typed-query methods build Q objects with `parent__name` paths. +""" + +from __future__ import annotations + +from app.examples.models.delete import ChildCascade, DeleteParent +from app.examples.models.relationships import Tag, Widget, WidgetTag + +from plain.postgres.query_utils import Q + + +def test_fk_field_access_builds_prefixed_q(): + q = ChildCascade.parent.name.equals("foo") + assert isinstance(q, Q) + assert q.children == [("parent__name", "foo")] + + +def test_fk_field_access_supports_other_lookups(): + assert ChildCascade.parent.name.startswith("a").children == [ + ("parent__name__startswith", "a") + ] + assert ChildCascade.parent.name.is_null().children == [ + ("parent__name__isnull", True) + ] + + +def test_fk_traversal_in_where_clause(db): + """End-to-end: build a query through the FK and verify it runs.""" + parent = DeleteParent.query.create(name="alice") + other = DeleteParent.query.create(name="bob") + ChildCascade.query.create(parent=parent) + ChildCascade.query.create(parent=other) + + matches = list(ChildCascade.query.where(ChildCascade.parent.name.equals("alice"))) + assert len(matches) == 1 + assert matches[0].parent.id == parent.id + + +def test_fk_traversal_combines_with_local_conditions(db): + """Mix a traversal condition with a local condition via &.""" + p1 = DeleteParent.query.create(name="alice") + p2 = DeleteParent.query.create(name="alice") + ChildCascade.query.create(parent=p1) + ChildCascade.query.create(parent=p2) + + matches = list( + ChildCascade.query.where( + ChildCascade.parent.name.equals("alice"), + ChildCascade.id.gte(0), + ) + ) + assert {c.parent.id for c in matches} == {p1.id, p2.id} + + +def test_multiple_fks_on_one_model(db): + """WidgetTag has FKs to both Widget and Tag — each path resolves independently.""" + w = Widget.query.create(name="cog", size="small") + t = Tag.query.create(name="metal") + WidgetTag.query.create(widget=w, tag=t) + + assert WidgetTag.query.where(WidgetTag.widget.name.equals("cog")).count() == 1 + assert WidgetTag.query.where(WidgetTag.tag.name.equals("metal")).count() == 1 + assert WidgetTag.query.where(WidgetTag.widget.name.equals("missing")).count() == 0 + + +def test_fk_traversal_or_combination(db): + p1 = DeleteParent.query.create(name="alice") + p2 = DeleteParent.query.create(name="bob") + p3 = DeleteParent.query.create(name="carol") + ChildCascade.query.create(parent=p1) + ChildCascade.query.create(parent=p2) + ChildCascade.query.create(parent=p3) + + matches = list( + ChildCascade.query.where( + ChildCascade.parent.name.equals("alice") + | ChildCascade.parent.name.equals("carol") + ) + ) + assert {c.parent.name for c in matches} == {"alice", "carol"} + + +def test_unknown_attribute_on_related_raises_attribute_error(): + """Traversal into a non-existent field on the related model fails loudly, + not silently producing a wrong-shaped Q.""" + import pytest + + with pytest.raises(AttributeError): + ChildCascade.parent.nonexistent_field # ty: ignore[unresolved-attribute] diff --git a/plain-redirection/plain/redirection/models.py b/plain-redirection/plain/redirection/models.py index 61ad863ece..d34efdceec 100644 --- a/plain-redirection/plain/redirection/models.py +++ b/plain-redirection/plain/redirection/models.py @@ -93,7 +93,7 @@ def get_redirect_url(self, request: Request) -> str: @postgres.register_model class RedirectLog(postgres.Model): - redirect: Redirect = types.ForeignKeyField(Redirect, on_delete=postgres.CASCADE) + redirect = types.ForeignKeyField(Redirect, on_delete=postgres.CASCADE) # The actuals that were used to redirect from_url = types.URLField(max_length=512) From 5a78e38acc5ea9f9871e99cb4a9b37df254be81d Mon Sep 17 00:00:00 2001 From: Dave Gaeddert Date: Sun, 24 May 2026 22:58:46 -0500 Subject: [PATCH 05/21] Fix FK traversal review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Encrypted-field bypass: PrefixedFieldRef now calls _reject_if_blocked in equals/not_equal/gt/gte/lt/lte and the string lookups. If the wrapped field is an EncryptedFieldMixin instance, raise TypeError with the same "use .is_null() instead" hint the direct-access path gives. The SQL-layer block (_exact_for_encrypted) still catches it as a second line of defense, but the typed-API now fails at the call site for clearer stack traces. 2. Multi-hop coverage: added tests exercising the RelatedFieldRef → RelatedFieldRef → PrefixedFieldRef recursion via Grandchild.mid_parent.grandparent.name, both as Q construction and end-to-end query. 3. Bool slip-through: documented the Python `bool <: int` quirk in the _ForeignKeyDescriptor stub comment so future maintainers know the runtime check in ForwardForeignKeyDescriptor.__set__ is the only guard. (No clean way to exclude bool from `int` in Python's type system.) 4. Descriptor attribute shadowing: documented in the RelatedFieldRef docstring with a pinned test asserting the current behavior (where a field named `field` on the related model would be shadowed by the descriptor's own `.field`). The architectural fix — returning a fresh proxy from __get__(instance=None) — is bigger and deferred. --- .../plain/postgres/fields/related_typed.py | 43 ++++++ plain-postgres/plain/postgres/types.pyi | 6 + .../tests/public/test_typed_where_fk.py | 136 +++++++++++++++++- 3 files changed, 182 insertions(+), 3 deletions(-) diff --git a/plain-postgres/plain/postgres/fields/related_typed.py b/plain-postgres/plain/postgres/fields/related_typed.py index a066d31392..053e3ed817 100644 --- a/plain-postgres/plain/postgres/fields/related_typed.py +++ b/plain-postgres/plain/postgres/fields/related_typed.py @@ -24,6 +24,23 @@ class RelatedFieldRef: Yielded by `ForwardForeignKeyDescriptor.__getattr__` for the first hop; chained traversal (`Order.user.profile.city`) builds nested `RelatedFieldRef` instances until a concrete field is reached. + + Known limitation — descriptor attribute shadowing + ------------------------------------------------- + For the first hop, `Child.parent` is the FK descriptor itself. + `__getattr__` only fires when normal attribute lookup *fails*, so if a + related model defines a field whose name collides with a public + attribute on `ForwardForeignKeyDescriptor` — currently `field`, + `is_cached`, `get_queryset`, `get_prefetch_queryset`, or + `RelatedObjectDoesNotExist` — `Child.parent.` silently + returns the descriptor's attribute instead of building a `PrefixedFieldRef`. + The typed-where call against it then produces wrong SQL. + + The architectural fix is to return a fresh proxy object from + `ForwardForeignKeyDescriptor.__get__(instance=None)` instead of `self`, + so the descriptor's own attributes aren't reachable through class + access. That's a bigger change with a wider blast radius (framework + code reads `Child.parent.field` etc.) and is deferred. """ def __init__(self, model: type[Model], prefix: str) -> None: @@ -88,21 +105,27 @@ def __repr__(self) -> str: # the base Field methods produce via _build_q, so SQL resolution is the # same as for a direct field reference. def equals(self, value: Any) -> Q: + self._reject_if_blocked("equals") return self._q("", value) def not_equal(self, value: Any) -> Q: + self._reject_if_blocked("not_equal") return ~self._q("", value) def gt(self, value: Any) -> Q: + self._reject_if_blocked("gt") return self._q("gt", value) def gte(self, value: Any) -> Q: + self._reject_if_blocked("gte") return self._q("gte", value) def lt(self, value: Any) -> Q: + self._reject_if_blocked("lt") return self._q("lt", value) def lte(self, value: Any) -> Q: + self._reject_if_blocked("lte") return self._q("lte", value) def is_null(self, value: bool = True) -> Q: @@ -115,15 +138,19 @@ def is_null(self, value: bool = True) -> Q: # query time, which is the same failure mode as a manual # `filter(user__priority__contains=...)`. def contains(self, value: str) -> Q: + self._reject_if_blocked("contains") return self._q("contains", value) def icontains(self, value: str) -> Q: + self._reject_if_blocked("icontains") return self._q("icontains", value) def startswith(self, value: str) -> Q: + self._reject_if_blocked("startswith") return self._q("startswith", value) def endswith(self, value: str) -> Q: + self._reject_if_blocked("endswith") return self._q("endswith", value) def _q(self, suffix: str, value: Any) -> Q: @@ -131,3 +158,19 @@ def _q(self, suffix: str, value: Any) -> Q: q = Q() q.children.append((key, value)) return q + + def _reject_if_blocked(self, method_name: str) -> None: + """Forward the typed-query block from fields that reject value + comparisons (currently EncryptedFieldMixin). Direct access raises + TypeError at the call site; without this hook, traversing through + a relation (Order.user.api_token.equals(...)) would silently build + a Q that only errors later at SQL build time.""" + from plain.postgres.fields.encrypted import EncryptedFieldMixin + + if isinstance(self._field, EncryptedFieldMixin): + field_name = getattr(self._field, "name", None) or "" + raise TypeError( + f"Encrypted field {field_name!r} (reached via " + f"{self._prefix!r}) does not support .{method_name}() — " + "ciphertext is non-deterministic. Use .is_null() instead." + ) diff --git a/plain-postgres/plain/postgres/types.pyi b/plain-postgres/plain/postgres/types.pyi index 4cff1f499e..3aa7189b2d 100644 --- a/plain-postgres/plain/postgres/types.pyi +++ b/plain-postgres/plain/postgres/types.pyi @@ -451,6 +451,12 @@ class _ForeignKeyDescriptor[T: Model, V]: def __get__(self, instance: None, owner: type) -> type[T]: ... @overload def __get__(self, instance: Model, owner: type) -> V: ... + # __set__ accepts the related instance, None (for nullable FKs via V), + # or a bare PK value (int). NOTE: `bool` is a subclass of `int` in Python, + # so `child.parent = True` type-checks here. The runtime + # `ForwardForeignKeyDescriptor.__set__` explicitly rejects bool with + # `ValueError` so this language quirk is caught at runtime, not silently + # coerced to PK 0/1. def __set__(self, instance: Model, value: V | int) -> None: ... @overload diff --git a/plain-postgres/tests/public/test_typed_where_fk.py b/plain-postgres/tests/public/test_typed_where_fk.py index 6ed0b97f3b..c15d792c0f 100644 --- a/plain-postgres/tests/public/test_typed_where_fk.py +++ b/plain-postgres/tests/public/test_typed_where_fk.py @@ -7,7 +7,15 @@ from __future__ import annotations -from app.examples.models.delete import ChildCascade, DeleteParent +import pytest +from app.examples.models.delete import ( + ChildCascade, + DeleteParent, + Grandchild, + Grandparent, + MidParent, +) +from app.examples.models.encrypted import SecretStore from app.examples.models.relationships import Tag, Widget, WidgetTag from plain.postgres.query_utils import Q @@ -87,7 +95,129 @@ def test_fk_traversal_or_combination(db): def test_unknown_attribute_on_related_raises_attribute_error(): """Traversal into a non-existent field on the related model fails loudly, not silently producing a wrong-shaped Q.""" - import pytest - with pytest.raises(AttributeError): ChildCascade.parent.nonexistent_field # ty: ignore[unresolved-attribute] + + +# --------------------------------------------------------------------------- +# Multi-hop traversal: Grandchild -> MidParent -> Grandparent +# --------------------------------------------------------------------------- + + +def test_two_hop_traversal_builds_double_prefixed_q(): + q = Grandchild.mid_parent.grandparent.name.equals("alice") + assert q.children == [("mid_parent__grandparent__name", "alice")] + + +def test_two_hop_traversal_runs(db): + g1 = Grandparent.query.create(name="alice") + g2 = Grandparent.query.create(name="bob") + m1 = MidParent.query.create(grandparent=g1) + m2 = MidParent.query.create(grandparent=g2) + Grandchild.query.create(mid_parent=m1) + Grandchild.query.create(mid_parent=m2) + + matches = list( + Grandchild.query.where(Grandchild.mid_parent.grandparent.name.equals("alice")) + ) + assert {gc.mid_parent.grandparent.name for gc in matches} == {"alice"} + + +def test_two_hop_chain_combines_with_or(db): + g1 = Grandparent.query.create(name="alice") + g2 = Grandparent.query.create(name="bob") + g3 = Grandparent.query.create(name="carol") + m1 = MidParent.query.create(grandparent=g1) + m2 = MidParent.query.create(grandparent=g2) + m3 = MidParent.query.create(grandparent=g3) + Grandchild.query.create(mid_parent=m1) + Grandchild.query.create(mid_parent=m2) + Grandchild.query.create(mid_parent=m3) + + matches = list( + Grandchild.query.where( + Grandchild.mid_parent.grandparent.name.equals("alice") + | Grandchild.mid_parent.grandparent.name.startswith("c") + ) + ) + assert {gc.mid_parent.grandparent.name for gc in matches} == {"alice", "carol"} + + +# --------------------------------------------------------------------------- +# Encrypted field traversal — comparison must be rejected, mirroring the +# direct-access behavior added in the previous commit. +# --------------------------------------------------------------------------- + + +class TestEncryptedFieldTraversalBlocked: + """A model with an FK to an encrypted-field-bearing model. Direct access + (SecretStore.api_key.equals) raises TypeError. Traversal must too — + otherwise the typed-API guard becomes a per-call-site instead of a + per-field guarantee.""" + + def test_traversed_equals_raises(self, db): + # WidgetTag doesn't have an FK to SecretStore, so we construct a + # synthetic traversal via PrefixedFieldRef directly. This is the + # same code path Order.relation.api_key.equals(...) would use. + from plain.postgres.fields.related_typed import PrefixedFieldRef + + ref = PrefixedFieldRef( + field=SecretStore._model_meta.get_field("api_key"), + prefix="store__api_key", + ) + with pytest.raises( + TypeError, match=r"Encrypted field.*does not support \.equals\(" + ): + ref.equals("x") + + def test_traversed_ordering_raises(self): + from plain.postgres.fields.related_typed import PrefixedFieldRef + + ref = PrefixedFieldRef( + field=SecretStore._model_meta.get_field("api_key"), + prefix="store__api_key", + ) + for method in ("not_equal", "gt", "gte", "lt", "lte", "contains"): + with pytest.raises(TypeError, match=rf"does not support \.{method}\("): + getattr(ref, method)("x") + + def test_traversed_is_null_still_works(self): + from plain.postgres.fields.related_typed import PrefixedFieldRef + + ref = PrefixedFieldRef( + field=SecretStore._model_meta.get_field("api_key"), + prefix="store__api_key", + ) + q = ref.is_null() + assert q.children == [("store__api_key__isnull", True)] + + +# --------------------------------------------------------------------------- +# Known limitation: descriptor attributes shadow same-named related fields. +# --------------------------------------------------------------------------- + + +def test_known_limitation_descriptor_attr_shadows_field_name(): + """ForwardForeignKeyDescriptor has public attributes (`field`, + `is_cached`, `get_queryset`, `get_prefetch_queryset`, + `RelatedObjectDoesNotExist`). __getattr__ doesn't fire when normal + attribute lookup succeeds, so if a related model defines a field with + one of those names, traversal silently returns the descriptor's own + attribute instead of building a typed Q. + + This test pins current behavior so a future fix won't regress + silently. It does NOT use a model with a colliding field name — none + of the existing fixture models do — it asserts the access pattern that + would shadow.""" + # `.field` on the descriptor is the FK Field instance — not a Q-builder. + # ty's view (via the typing lie) is that `ChildCascade.parent` is + # `type[DeleteParent]`, which doesn't have `.field` — this access is + # only meaningful at runtime. + fk_field = ChildCascade.parent.field # ty: ignore[unresolved-attribute] + from plain.postgres.fields.related import ForeignKeyField + + assert isinstance(fk_field, ForeignKeyField) + # If someone wrote `class DeleteParent(Model): field = TextField()`, + # `ChildCascade.parent.field.equals("x")` would build Q(parent="x") + # via the FK's own .equals, NOT Q(parent__field="x"). See the docstring + # on RelatedFieldRef for the full list of shadowed names. From 18074907c0702d5e0e5ccc47580587960db7f902 Mon Sep 17 00:00:00 2001 From: Dave Gaeddert Date: Sun, 24 May 2026 23:09:47 -0500 Subject: [PATCH 06/21] Close assignment-typing gap on Field.__set__ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field.__set__ was already a data descriptor at runtime, but its parameter was typed `value: Any` — so ty silently allowed wrong-type assignment like `row.name = 123` on a TextField. Narrow to `value: T` so the type checker enforces the declared field type at the call site. Plain's runtime is more permissive (`to_python` converts strings → ints etc.), but encouraging explicit conversion at the boundary catches a real bug class and makes the new typed-field declarations actually pull their weight. Tests added: a TYPE_CHECKING-only block with deliberately wrong assignments and `# ty: ignore[invalid-assignment]` markers. If the type-check ever loosens, ty flags the markers as unused suppressions — making regressions visible. --- plain-postgres/plain/postgres/fields/base.py | 19 ++++++++++--- .../tests/public/test_typed_where.py | 27 ++++++++++++++++++- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/plain-postgres/plain/postgres/fields/base.py b/plain-postgres/plain/postgres/fields/base.py index 6e9cc172ff..e4e256e256 100644 --- a/plain-postgres/plain/postgres/fields/base.py +++ b/plain-postgres/plain/postgres/fields/base.py @@ -473,12 +473,18 @@ def __get__(self, instance: Model | None, owner: type[Model]) -> Self | T: return cast(T, data.get(field_name)) - def __set__(self, instance: Model, value: Any) -> None: + def __set__(self, instance: Model, value: T) -> None: """ Descriptor __set__ for attribute assignment. Validates and converts the value using to_python(), then stores it in instance.__dict__[name]. + + The parameter is typed `T` (the field's value type) so a type checker + rejects assigning incompatible types — `row.name = 123` on a + TextField is caught at the call site. The runtime is more permissive + (to_python converts strings → ints etc.), but encouraging explicit + conversion at the boundary is the better default. """ # Safety check: ensure field has been properly initialized if not hasattr(self, "column"): @@ -490,12 +496,17 @@ def __set__(self, instance: Model, value: Any) -> None: # Convert/validate the value. The DATABASE_DEFAULT sentinel is stored # as-is so the INSERT compiler can emit `DEFAULT` in the VALUES clause. - if value is not None and value is not DATABASE_DEFAULT: - value = self.to_python(value) + # Use a separate local so the parameter's narrow `T` type isn't + # widened by to_python's `T | None` return. + stored: Any + if value is None or value is DATABASE_DEFAULT: + stored = value + else: + stored = self.to_python(value) # Store in instance dict assert self.name is not None - instance.__dict__[self.name] = value + instance.__dict__[self.name] = stored def __delete__(self, instance: Model) -> None: """ diff --git a/plain-postgres/tests/public/test_typed_where.py b/plain-postgres/tests/public/test_typed_where.py index 904727d004..41f1e620d4 100644 --- a/plain-postgres/tests/public/test_typed_where.py +++ b/plain-postgres/tests/public/test_typed_where.py @@ -7,7 +7,7 @@ from __future__ import annotations -from typing import assert_type +from typing import TYPE_CHECKING, assert_type from app.examples.models.defaults import DefaultsExample @@ -35,6 +35,31 @@ def test_instance_access_yields_value_type() -> None: assert_type(row.priority, int) +def test_assignment_typing_accepts_value_type() -> None: + """Assignment to a field instance accepts the declared value type.""" + row = DefaultsExample(name="x", note=None, priority=1) + row.name = "y" # str → str: OK + row.priority = 99 # int → int: OK + row.note = None # None → str | None: OK (nullable) + row.note = "set" # str → str | None: OK + + +if TYPE_CHECKING: + # Type-check only: these assignments must be flagged by ty. The ignore + # markers are load-bearing — if Field.__set__ were typed loosely + # (e.g. value: Any), ty would report them as unused suppressions. + # Their presence here proves the type checker enforces T. We avoid + # running the assignments at runtime because Field.__set__ also calls + # to_python() which raises ValidationError on unconvertible input. + def _typed_check_rejects_wrong_assignment() -> None: + row = DefaultsExample(name="x", note=None, priority=1) + row.name = 123 # ty: ignore[invalid-assignment] + row.priority = "no" # ty: ignore[invalid-assignment] + # Non-nullable field rejects None at type-check time even though the + # runtime would store it (and only fail later at validate/save). + row.name = None # ty: ignore[invalid-assignment] + + def test_field_methods_return_q_objects(): """The methods are usable before any DB hit and produce Q objects.""" assert isinstance(DefaultsExample.name.equals("foo"), Q) From f32c3ad8119a9769f473915d61ec2b0aaa0cb648 Mon Sep 17 00:00:00 2001 From: Dave Gaeddert Date: Thu, 23 Jul 2026 10:34:02 -0500 Subject: [PATCH 07/21] Fix FK descriptor shadowing in typed where() traversal Class-level access to a forward FK (Child.parent) now returns a fresh RelatedFieldRef traversal proxy from __get__ instead of the descriptor itself. Attribute lookup on the proxy reads the related model with inspect.getattr_static, so a related field whose name collides with a public descriptor attribute (field, is_cached, get_queryset, get_prefetch_queryset) resolves to the field rather than silently returning the descriptor attribute and building wrong SQL. Prefetch machinery, the only code that needs the descriptor via class-level access, now reaches it with inspect.getattr_static in get_prefetcher to bypass the proxy. --- .../postgres/fields/related_descriptors.py | 49 ++++++--------- .../plain/postgres/fields/related_typed.py | 62 ++++++++----------- plain-postgres/plain/postgres/query.py | 7 ++- .../0019_shadowtarget_shadowsource.py | 35 +++++++++++ .../tests/app/examples/models/__init__.py | 1 + .../tests/app/examples/models/shadowing.py | 25 ++++++++ .../tests/public/test_typed_where_fk.py | 62 +++++++++++-------- 7 files changed, 149 insertions(+), 92 deletions(-) create mode 100644 plain-postgres/tests/app/examples/migrations/0019_shadowtarget_shadowsource.py create mode 100644 plain-postgres/tests/app/examples/models/shadowing.py diff --git a/plain-postgres/plain/postgres/fields/related_descriptors.py b/plain-postgres/plain/postgres/fields/related_descriptors.py index ec0e26e35f..e81766f5df 100644 --- a/plain-postgres/plain/postgres/fields/related_descriptors.py +++ b/plain-postgres/plain/postgres/fields/related_descriptors.py @@ -106,35 +106,7 @@ def get_prefetch_queryset( False, ) - def __getattr__(self, name: str) -> Any: - """Proxy class-level attribute access to the related model so typed - where() can traverse the relation: - - Child.parent.name.equals("x") → Q(parent__name="x") - - Only triggers for attributes not found on the descriptor itself. - Returns AttributeError for dunders / private names so pickling, - copy.deepcopy, and hasattr() probes fail cleanly. - """ - if name.startswith("_"): - raise AttributeError(name) - from plain.postgres.fields.related_typed import RelatedFieldRef - - remote_model = self.field.remote_field.model - if isinstance(remote_model, str): - # Relation not yet resolved (still a lazy string ref). Fail - # loudly rather than silently producing wrong-shaped queries. - raise AttributeError( - f"Cannot traverse {self.field.name!r}: related model has " - "not been registered yet." - ) - return getattr( - RelatedFieldRef(model=remote_model, prefix=self.field.name), name - ) - - def __get__( - self, instance: Any | None, cls: type | None = None - ) -> ForwardForeignKeyDescriptor | Any | None: + def __get__(self, instance: Any | None, cls: type | None = None) -> Any: """ Get the related instance through the forward relation. @@ -143,9 +115,26 @@ def __get__( - ``self`` is the descriptor managing the ``parent`` attribute - ``instance`` is the ``child`` instance - ``cls`` is the ``Child`` class (we don't need it) + + Class-level access (``Child.parent``) returns a fresh + ``RelatedFieldRef`` traversal proxy so typed where() can walk into the + related model's fields: + + Child.parent.name.equals("x") → Q(parent__name="x") + + The proxy exposes only traversal machinery, so a related field whose + name collides with one of this descriptor's own attributes (``field``, + ``is_cached``, ``get_queryset`` …) still resolves to the field, not the + descriptor attribute. Framework code that needs the descriptor itself + (prefetching) reaches it with ``inspect.getattr_static`` to bypass this + proxy. """ if instance is None: - return self + from plain.postgres.fields.related_typed import RelatedFieldRef + + return RelatedFieldRef( + model=self.field.remote_field.model, prefix=self.field.name + ) # The related object is cached on the model state -- by select_related, # prefetch, the reverse accessor, a prior access, or assignment. diff --git a/plain-postgres/plain/postgres/fields/related_typed.py b/plain-postgres/plain/postgres/fields/related_typed.py index 053e3ed817..b11a7f50f0 100644 --- a/plain-postgres/plain/postgres/fields/related_typed.py +++ b/plain-postgres/plain/postgres/fields/related_typed.py @@ -3,12 +3,14 @@ When `Order.user` is a ForeignKey, accessing `.email` at the class level (as in `where(Order.user.email.equals("x"))`) needs to produce `Q(user__email="x")` so the existing SQL builder's join machinery resolves the -right column. The descriptor doesn't expose the related model's fields directly, -so we proxy attribute access through these two helpers. +right column. `ForwardForeignKeyDescriptor.__get__` returns a `RelatedFieldRef` +for class-level access, and these two helpers walk attribute access into the +related model to build the lookup path. """ from __future__ import annotations +import inspect from typing import TYPE_CHECKING, Any from plain.postgres.query_utils import Q @@ -21,65 +23,55 @@ class RelatedFieldRef: """Class-level proxy that walks attribute access into the related model and accumulates the lookup path prefix as it goes. - Yielded by `ForwardForeignKeyDescriptor.__getattr__` for the first hop; + Returned by `ForwardForeignKeyDescriptor.__get__` for the first hop; chained traversal (`Order.user.profile.city`) builds nested `RelatedFieldRef` instances until a concrete field is reached. - Known limitation — descriptor attribute shadowing - ------------------------------------------------- - For the first hop, `Child.parent` is the FK descriptor itself. - `__getattr__` only fires when normal attribute lookup *fails*, so if a - related model defines a field whose name collides with a public - attribute on `ForwardForeignKeyDescriptor` — currently `field`, - `is_cached`, `get_queryset`, `get_prefetch_queryset`, or - `RelatedObjectDoesNotExist` — `Child.parent.` silently - returns the descriptor's attribute instead of building a `PrefixedFieldRef`. - The typed-where call against it then produces wrong SQL. - - The architectural fix is to return a fresh proxy object from - `ForwardForeignKeyDescriptor.__get__(instance=None)` instead of `self`, - so the descriptor's own attributes aren't reachable through class - access. That's a bigger change with a wider blast radius (framework - code reads `Child.parent.field` etc.) and is deferred. + The proxy's own namespace holds only traversal machinery, so a related + field whose name collides with a public attribute on the FK descriptor + (`field`, `is_cached`, `get_queryset`, …) still resolves to that field. + Attribute lookup reads the related model with `inspect.getattr_static`, + which returns the raw field/descriptor without invoking its `__get__`. """ - def __init__(self, model: type[Model], prefix: str) -> None: + def __init__(self, model: type[Model] | str, prefix: str) -> None: self._model = model self._prefix = prefix def __repr__(self) -> str: - return f"" + name = self._model if isinstance(self._model, str) else self._model.__name__ + return f"" def __getattr__(self, name: str) -> Any: if name.startswith("_"): # Avoid infinite recursion on internals and let pickling/hasattr # checks fail cleanly. raise AttributeError(name) + if isinstance(self._model, str): + # Relation not yet resolved (still a lazy string ref). Fail + # loudly rather than silently producing wrong-shaped queries. + raise AttributeError( + f"Cannot traverse {self._prefix}.{name}: relation is " + "still a string reference; the related model has not " + "been registered yet." + ) from plain.postgres.fields.base import Field from plain.postgres.fields.related_descriptors import ( ForwardForeignKeyDescriptor, ) try: - attr = self._model.__dict__[name] - except KeyError: - # Fall back to a full lookup so inherited fields resolve. - attr = getattr(self._model, name, None) - if attr is None: - raise AttributeError(name) from None + attr = inspect.getattr_static(self._model, name) + except AttributeError: + raise AttributeError(name) from None next_prefix = f"{self._prefix}__{name}" if isinstance(attr, Field): return PrefixedFieldRef(field=attr, prefix=next_prefix) if isinstance(attr, ForwardForeignKeyDescriptor): - remote_model = attr.field.remote_field.model - if isinstance(remote_model, str): - raise AttributeError( - f"Cannot traverse {self._prefix}.{name}: relation is " - "still a string reference; the related model has not " - "been registered yet." - ) - return RelatedFieldRef(model=remote_model, prefix=next_prefix) + return RelatedFieldRef( + model=attr.field.remote_field.model, prefix=next_prefix + ) raise AttributeError( f"{self._prefix}.{name} is not a traversable field or relation" ) diff --git a/plain-postgres/plain/postgres/query.py b/plain-postgres/plain/postgres/query.py index bcd19b0fd6..8ae2b439fc 100644 --- a/plain-postgres/plain/postgres/query.py +++ b/plain-postgres/plain/postgres/query.py @@ -5,6 +5,7 @@ from __future__ import annotations import copy +import inspect import operator import warnings from collections.abc import Callable, Iterator, Sequence @@ -1927,8 +1928,10 @@ def has_to_attr_attribute(instance: Model) -> bool: # For singly related objects, we have to avoid getting the attribute # from the object, as this will trigger the query. So we first try - # on the class, in order to get the descriptor object. - rel_obj_descriptor = getattr(instance.__class__, through_attr, None) + # on the class, in order to get the descriptor object. Use + # getattr_static so a forward FK yields its descriptor rather than the + # RelatedFieldRef traversal proxy its __get__ returns for class access. + rel_obj_descriptor = inspect.getattr_static(instance.__class__, through_attr, None) if rel_obj_descriptor is None: attr_found = hasattr(instance, through_attr) else: diff --git a/plain-postgres/tests/app/examples/migrations/0019_shadowtarget_shadowsource.py b/plain-postgres/tests/app/examples/migrations/0019_shadowtarget_shadowsource.py new file mode 100644 index 0000000000..99a8cb6cb5 --- /dev/null +++ b/plain-postgres/tests/app/examples/migrations/0019_shadowtarget_shadowsource.py @@ -0,0 +1,35 @@ +# Generated by Plain 0.154.0 on 2026-07-23 15:32 + +from plain import postgres +from plain.postgres import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("examples", "0018_storageparametersexample"), + ] + + operations = [ + migrations.CreateModel( + name="ShadowTarget", + fields=[ + ("id", postgres.PrimaryKeyField()), + ("field", postgres.TextField(max_length=100)), + ("get_prefetch_queryset", postgres.TextField(max_length=100)), + ("get_queryset", postgres.TextField(max_length=100)), + ("is_cached", postgres.TextField(max_length=100)), + ], + ), + migrations.CreateModel( + name="ShadowSource", + fields=[ + ("id", postgres.PrimaryKeyField()), + ( + "ref", + postgres.ForeignKeyField( + on_delete=postgres.CASCADE, to="examples.shadowtarget" + ), + ), + ], + ), + ] diff --git a/plain-postgres/tests/app/examples/models/__init__.py b/plain-postgres/tests/app/examples/models/__init__.py index 66e6b7039b..be3f08bf59 100644 --- a/plain-postgres/tests/app/examples/models/__init__.py +++ b/plain-postgres/tests/app/examples/models/__init__.py @@ -13,6 +13,7 @@ nullability, querysets, relationships, + shadowing, storage_parameters, trees, unregistered, diff --git a/plain-postgres/tests/app/examples/models/shadowing.py b/plain-postgres/tests/app/examples/models/shadowing.py new file mode 100644 index 0000000000..137c5ae694 --- /dev/null +++ b/plain-postgres/tests/app/examples/models/shadowing.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from plain import postgres +from plain.postgres import types + + +@postgres.register_model +class ShadowTarget(postgres.Model): + """Related model whose field names collide with public attributes on + ForwardForeignKeyDescriptor. Traversal through the FK must resolve these + to the fields, not the descriptor's own attributes.""" + + field = types.TextField(max_length=100) + is_cached = types.TextField(max_length=100) + get_queryset = types.TextField(max_length=100) + get_prefetch_queryset = types.TextField(max_length=100) + + query: postgres.QuerySet[ShadowTarget] = postgres.QuerySet() + + +@postgres.register_model +class ShadowSource(postgres.Model): + ref = types.ForeignKeyField(ShadowTarget, on_delete=postgres.CASCADE) + + query: postgres.QuerySet[ShadowSource] = postgres.QuerySet() diff --git a/plain-postgres/tests/public/test_typed_where_fk.py b/plain-postgres/tests/public/test_typed_where_fk.py index c15d792c0f..4dbb3b856b 100644 --- a/plain-postgres/tests/public/test_typed_where_fk.py +++ b/plain-postgres/tests/public/test_typed_where_fk.py @@ -17,6 +17,7 @@ ) from app.examples.models.encrypted import SecretStore from app.examples.models.relationships import Tag, Widget, WidgetTag +from app.examples.models.shadowing import ShadowSource, ShadowTarget from plain.postgres.query_utils import Q @@ -193,31 +194,42 @@ def test_traversed_is_null_still_works(self): # --------------------------------------------------------------------------- -# Known limitation: descriptor attributes shadow same-named related fields. +# Descriptor attribute shadowing: a related field whose name collides with a +# public attribute on ForwardForeignKeyDescriptor (`field`, `is_cached`, +# `get_queryset`, `get_prefetch_queryset`) must still traverse to the field. +# `__get__` returns a RelatedFieldRef proxy for class access, so the +# descriptor's own attributes are unreachable through the relation. # --------------------------------------------------------------------------- -def test_known_limitation_descriptor_attr_shadows_field_name(): - """ForwardForeignKeyDescriptor has public attributes (`field`, - `is_cached`, `get_queryset`, `get_prefetch_queryset`, - `RelatedObjectDoesNotExist`). __getattr__ doesn't fire when normal - attribute lookup succeeds, so if a related model defines a field with - one of those names, traversal silently returns the descriptor's own - attribute instead of building a typed Q. - - This test pins current behavior so a future fix won't regress - silently. It does NOT use a model with a colliding field name — none - of the existing fixture models do — it asserts the access pattern that - would shadow.""" - # `.field` on the descriptor is the FK Field instance — not a Q-builder. - # ty's view (via the typing lie) is that `ChildCascade.parent` is - # `type[DeleteParent]`, which doesn't have `.field` — this access is - # only meaningful at runtime. - fk_field = ChildCascade.parent.field # ty: ignore[unresolved-attribute] - from plain.postgres.fields.related import ForeignKeyField - - assert isinstance(fk_field, ForeignKeyField) - # If someone wrote `class DeleteParent(Model): field = TextField()`, - # `ChildCascade.parent.field.equals("x")` would build Q(parent="x") - # via the FK's own .equals, NOT Q(parent__field="x"). See the docstring - # on RelatedFieldRef for the full list of shadowed names. +@pytest.mark.parametrize( + "name", + ["field", "is_cached", "get_queryset", "get_prefetch_queryset"], +) +def test_shadowed_field_name_traverses_to_field(name): + """ShadowTarget defines fields named after descriptor attributes. + Traversal through ShadowSource.ref resolves each to the field, building + a `ref__` path rather than returning the descriptor's attribute.""" + ref = getattr(ShadowSource.ref, name) + q = ref.equals("x") + assert q.children == [(f"ref__{name}", "x")] + + +def test_shadowed_field_traversal_runs(db): + """End-to-end: a where() through the shadowed `field` name filters rows.""" + matched = ShadowTarget.query.create( + field="hit", + is_cached="a", + get_queryset="b", + get_prefetch_queryset="c", + ) + ShadowTarget.query.create( + field="miss", + is_cached="a", + get_queryset="b", + get_prefetch_queryset="c", + ) + ShadowSource.query.create(ref=matched) + + rows = list(ShadowSource.query.where(ShadowSource.ref.field.equals("hit"))) + assert [r.ref.id for r in rows] == [matched.id] From 85717fd6777bba7dbe03910486799d3f7aaf9260 Mon Sep 17 00:00:00 2001 From: Dave Gaeddert Date: Thu, 23 Jul 2026 10:42:53 -0500 Subject: [PATCH 08/21] Add is_in membership condition to typed where() is_in builds a Q(field__in=[...]) membership condition on every field, typed as an iterable of the field's value type so a wrong element type is rejected at the call site. Negation composes with ~. FK traversal builds the prefixed path, and encrypted fields block it with a Never-typed parameter plus a runtime TypeError, matching the other comparisons. --- plain-postgres/plain/postgres/fields/base.py | 5 ++- .../plain/postgres/fields/encrypted.py | 3 ++ .../plain/postgres/fields/related_typed.py | 4 +++ .../tests/public/test_encrypted_fields.py | 4 +++ .../tests/public/test_typed_where.py | 36 +++++++++++++++++++ .../tests/public/test_typed_where_fk.py | 13 +++++++ 6 files changed, 64 insertions(+), 1 deletion(-) diff --git a/plain-postgres/plain/postgres/fields/base.py b/plain-postgres/plain/postgres/fields/base.py index e4e256e256..61107e62a6 100644 --- a/plain-postgres/plain/postgres/fields/base.py +++ b/plain-postgres/plain/postgres/fields/base.py @@ -3,7 +3,7 @@ import collections.abc import copy import enum -from collections.abc import Callable, Sequence +from collections.abc import Callable, Iterable, Sequence from functools import cached_property from typing import ( TYPE_CHECKING, @@ -176,6 +176,9 @@ def lte(self, value: T) -> Q: def is_null(self, value: bool = True) -> Q: return self._build_q("isnull", value) + def is_in(self, values: Iterable[T]) -> Q: + return self._build_q("in", values) + def _build_q(self, suffix: str, value: Any) -> Q: """Build a Q from a lookup suffix + value, bypassing Q's reserved `_connector`/`_negated` kwargs that confuse the type checker on diff --git a/plain-postgres/plain/postgres/fields/encrypted.py b/plain-postgres/plain/postgres/fields/encrypted.py index ca2b966e9c..481d9cd4f4 100644 --- a/plain-postgres/plain/postgres/fields/encrypted.py +++ b/plain-postgres/plain/postgres/fields/encrypted.py @@ -189,6 +189,9 @@ def lt(self, value: Never) -> Never: def lte(self, value: Never) -> Never: raise TypeError(self._lookup_unsupported_message("lte")) + def is_in(self, values: Never) -> Never: + raise TypeError(self._lookup_unsupported_message("is_in")) + def _lookup_unsupported_message(self, method: str) -> str: assert self.name is not None, ( "Encrypted field must be attached to a model before its typed-query " diff --git a/plain-postgres/plain/postgres/fields/related_typed.py b/plain-postgres/plain/postgres/fields/related_typed.py index b11a7f50f0..e3c4bff700 100644 --- a/plain-postgres/plain/postgres/fields/related_typed.py +++ b/plain-postgres/plain/postgres/fields/related_typed.py @@ -123,6 +123,10 @@ def lte(self, value: Any) -> Q: def is_null(self, value: bool = True) -> Q: return self._q("isnull", value) + def is_in(self, values: Any) -> Q: + self._reject_if_blocked("is_in") + return self._q("in", values) + # TextField-specific lookups — always exposed at the proxy layer because # callers go through the typing lie (`Order.user.email` reads as # TextField[str] to the type checker). At runtime, calling .contains on diff --git a/plain-postgres/tests/public/test_encrypted_fields.py b/plain-postgres/tests/public/test_encrypted_fields.py index a5ffcd56b2..d5a39d5579 100644 --- a/plain-postgres/tests/public/test_encrypted_fields.py +++ b/plain-postgres/tests/public/test_encrypted_fields.py @@ -165,6 +165,10 @@ def test_ordering_comparison_raises(self, method): with pytest.raises(TypeError, match=rf"does not support \.{method}\("): getattr(SecretStore.api_key, method)("x") + def test_is_in_raises(self): + with pytest.raises(TypeError, match=r"does not support \.is_in\("): + SecretStore.api_key.is_in(["x", "y"]) # ty: ignore[invalid-argument-type] + def test_is_null_returns_correct_lookup(self): """is_null is the one comparison that makes sense on ciphertext.""" from plain.postgres.query_utils import Q diff --git a/plain-postgres/tests/public/test_typed_where.py b/plain-postgres/tests/public/test_typed_where.py index 41f1e620d4..afcb506fcf 100644 --- a/plain-postgres/tests/public/test_typed_where.py +++ b/plain-postgres/tests/public/test_typed_where.py @@ -59,6 +59,15 @@ def _typed_check_rejects_wrong_assignment() -> None: # runtime would store it (and only fail later at validate/save). row.name = None # ty: ignore[invalid-assignment] + def _typed_check_is_in_element_type() -> None: + # is_in takes an iterable of the field's value type. A matching + # iterable type-checks clean; a wrong element type is flagged. The + # ignore marker is load-bearing — if the parameter were typed loosely + # (e.g. Iterable[Any]), ty would report it as an unused suppression. + DefaultsExample.priority.is_in([1, 2, 3]) + DefaultsExample.name.is_in(["a", "b"]) + DefaultsExample.priority.is_in(["no", "ints"]) # ty: ignore[invalid-argument-type] + def test_field_methods_return_q_objects(): """The methods are usable before any DB hit and produce Q objects.""" @@ -66,6 +75,7 @@ def test_field_methods_return_q_objects(): assert isinstance(DefaultsExample.priority.gte(5), Q) assert isinstance(DefaultsExample.name.contains("oo"), Q) assert isinstance(DefaultsExample.note.is_null(), Q) + assert isinstance(DefaultsExample.priority.is_in([1, 2]), Q) def test_where_filters_by_equals(db): @@ -124,6 +134,32 @@ def test_text_field_string_lookups(db): assert [r.name for r in starts] == ["alice", "alpha"] +def test_where_filters_by_is_in(db): + DefaultsExample.query.create(name="alice") + DefaultsExample.query.create(name="bob") + DefaultsExample.query.create(name="carol") + + rows = list( + DefaultsExample.query.where( + DefaultsExample.name.is_in(["alice", "carol"]) + ).order_by("name") + ) + assert [r.name for r in rows] == ["alice", "carol"] + + +def test_is_in_negation_excludes_members(db): + DefaultsExample.query.create(name="alice") + DefaultsExample.query.create(name="bob") + DefaultsExample.query.create(name="carol") + + rows = list( + DefaultsExample.query.where( + ~DefaultsExample.name.is_in(["alice", "carol"]) + ).order_by("name") + ) + assert [r.name for r in rows] == ["bob"] + + def test_is_null_with_explicit_default(db): DefaultsExample.query.create(name="alice", note=None) DefaultsExample.query.create(name="bob") # default "auto" diff --git a/plain-postgres/tests/public/test_typed_where_fk.py b/plain-postgres/tests/public/test_typed_where_fk.py index 4dbb3b856b..9549724411 100644 --- a/plain-postgres/tests/public/test_typed_where_fk.py +++ b/plain-postgres/tests/public/test_typed_where_fk.py @@ -35,6 +35,9 @@ def test_fk_field_access_supports_other_lookups(): assert ChildCascade.parent.name.is_null().children == [ ("parent__name__isnull", True) ] + assert ChildCascade.parent.name.is_in(["a", "b"]).children == [ + ("parent__name__in", ["a", "b"]) + ] def test_fk_traversal_in_where_clause(db): @@ -182,6 +185,16 @@ def test_traversed_ordering_raises(self): with pytest.raises(TypeError, match=rf"does not support \.{method}\("): getattr(ref, method)("x") + def test_traversed_is_in_raises(self): + from plain.postgres.fields.related_typed import PrefixedFieldRef + + ref = PrefixedFieldRef( + field=SecretStore._model_meta.get_field("api_key"), + prefix="store__api_key", + ) + with pytest.raises(TypeError, match=r"does not support \.is_in\("): + ref.is_in(["x", "y"]) + def test_traversed_is_null_still_works(self): from plain.postgres.fields.related_typed import PrefixedFieldRef From 3da7f534c048c5394dd5198d38cfdf313110839d Mon Sep 17 00:00:00 2001 From: Dave Gaeddert Date: Thu, 23 Jul 2026 10:45:51 -0500 Subject: [PATCH 09/21] Document typed where() conditions Add a Querying subsection covering where(), the condition methods on every field and on text fields, negation and combination, foreign-key traversal, and the encrypted-field restriction to is_null(). --- plain-postgres/plain/postgres/README.md | 40 +++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/plain-postgres/plain/postgres/README.md b/plain-postgres/plain/postgres/README.md index f06bb0cb1d..ee04b6a1b2 100644 --- a/plain-postgres/plain/postgres/README.md +++ b/plain-postgres/plain/postgres/README.md @@ -179,6 +179,46 @@ first_10_users = User.query.all()[:10] For more advanced querying options, see the [`QuerySet`](./query.py#QuerySet) class. +### Typed conditions with where() + +`where()` is a typed alternative to `filter()`. Instead of string keyword lookups, you build each condition from a field, so a type checker catches a misspelled field or a wrong value type at the call site: + +```python +from plain.postgres import types + +@postgres.register_model +class User(postgres.Model): + email: str = types.EmailField() + role: str = types.TextField(max_length=20) + age: int = types.IntegerField(allow_null=True) + + query: postgres.QuerySet[User] = postgres.QuerySet() + +# Each argument is a condition; multiple arguments are ANDed together. +admins = User.query.where( + User.role.equals("admin"), + User.age.gte(18), +) +``` + +Every field exposes `equals`, `not_equal`, `gt`, `gte`, `lt`, `lte`, `is_null`, and `is_in`. Text fields add `contains`, `icontains`, `startswith`, and `endswith`. Each returns a `Q`, so you can combine them with `|` and `&` or negate with `~`: + +```python +# Membership, negation, and OR +User.query.where(User.role.is_in(["admin", "staff"])) +User.query.where(~User.role.equals("guest")) +User.query.where(User.email.endswith("@example.com") | User.role.equals("admin")) +``` + +Conditions traverse foreign keys — accessing a field through a relation builds the joined lookup: + +```python +# Q(author__email="a@example.com") +Post.query.where(Post.author.email.equals("a@example.com")) +``` + +[Encrypted fields](#encrypted-fields) reject value comparisons because their ciphertext is non-deterministic — only `is_null()` is available, and any other condition method (`equals`, `is_in`, …) raises `TypeError`. + ### Custom QuerySets You can customize [`QuerySet`](./query.py#QuerySet) classes to provide specialized query methods. Define a custom QuerySet and assign it to your model's `query` attribute: From c9e8bebd4e9a4f268cd16748dc3bbffe7b3a9a84 Mon Sep 17 00:00:00 2001 From: Dave Gaeddert Date: Thu, 23 Jul 2026 11:12:45 -0500 Subject: [PATCH 10/21] Consolidate typed where() traversal onto delegation and metadata resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PrefixedFieldRef now delegates each condition to the wrapped field's own method and rewrites the resulting Q's leaf keys onto the relation path, instead of hand-mirroring every condition method. This makes the traversed surface exactly the field's own surface: a method the field doesn't define (contains on a non-text field) raises AttributeError through traversal, and encrypted-field blocking is enforced automatically by the field's own method bodies — no separate reject hook. RelatedFieldRef resolves names through the related model's metadata (get_forward_field) rather than attribute lookup, which is shadowing-immune by construction. Both refs now require a resolved model class; the dead string-model branch is gone. Field._build_q builds its Q via the positional-tuple constructor rather than poking children directly. --- plain-postgres/plain/postgres/README.md | 2 + plain-postgres/plain/postgres/fields/base.py | 10 +- .../plain/postgres/fields/encrypted.py | 15 +- .../plain/postgres/fields/related_typed.py | 220 ++++++++---------- .../tests/public/test_typed_where_fk.py | 46 +++- 5 files changed, 147 insertions(+), 146 deletions(-) diff --git a/plain-postgres/plain/postgres/README.md b/plain-postgres/plain/postgres/README.md index ee04b6a1b2..8a240cb524 100644 --- a/plain-postgres/plain/postgres/README.md +++ b/plain-postgres/plain/postgres/README.md @@ -217,6 +217,8 @@ Conditions traverse foreign keys — accessing a field through a relation builds Post.query.where(Post.author.email.equals("a@example.com")) ``` +A traversed field offers exactly the same conditions as the field itself — a text-only method like `contains` is available through the relation only when the related field is a text field. + [Encrypted fields](#encrypted-fields) reject value comparisons because their ciphertext is non-deterministic — only `is_null()` is available, and any other condition method (`equals`, `is_in`, …) raises `TypeError`. ### Custom QuerySets diff --git a/plain-postgres/plain/postgres/fields/base.py b/plain-postgres/plain/postgres/fields/base.py index 61107e62a6..e0f77252b3 100644 --- a/plain-postgres/plain/postgres/fields/base.py +++ b/plain-postgres/plain/postgres/fields/base.py @@ -180,17 +180,15 @@ def is_in(self, values: Iterable[T]) -> Q: return self._build_q("in", values) def _build_q(self, suffix: str, value: Any) -> Q: - """Build a Q from a lookup suffix + value, bypassing Q's reserved - `_connector`/`_negated` kwargs that confuse the type checker on - `**{name: value}` expansion.""" + """Build a Q from a lookup suffix + value. Uses Q's positional-tuple + constructor to bypass its reserved `_connector`/`_negated` kwargs that + confuse the type checker on `**{name: value}` expansion.""" assert self.name is not None, ( "Field name must be set before building a query condition; " "the field must be attached to a model." ) name = f"{self.name}__{suffix}" if suffix else self.name - q = Q() - q.children.append((name, value)) - return q + return Q((name, value)) def preflight(self, **kwargs: Any) -> list[PreflightResult]: return [*self._check_field_name()] diff --git a/plain-postgres/plain/postgres/fields/encrypted.py b/plain-postgres/plain/postgres/fields/encrypted.py index 481d9cd4f4..91ba65e4ba 100644 --- a/plain-postgres/plain/postgres/fields/encrypted.py +++ b/plain-postgres/plain/postgres/fields/encrypted.py @@ -104,6 +104,14 @@ def _decrypt(value: str) -> str: ) +# Shared tail explaining why encrypted fields reject value comparisons — used +# by both the lookup-construction guard (_exact_for_encrypted) and the +# typed-query method guard (_lookup_unsupported_message). +_NON_DETERMINISTIC_EXPLANATION = ( + "ciphertext is non-deterministic. Use .is_null() instead." +) + + # isnull is obviously needed. exact is required so that `filter(field=None)` # works — the ORM resolves "exact" first and then rewrites None to isnull. # get_lookup() below wraps the exact lookup class to reject non-None right-hand @@ -126,9 +134,8 @@ def __init__(self, lhs: Any, rhs: Any) -> None: field_name = getattr(target, "name", None) or "" raise TypeError( f"Encrypted field {field_name!r} cannot be filtered by " - "equality against a non-None value — ciphertext is " - "non-deterministic. Use Model.field.is_null() or " - "filter(field__isnull=True) for null checks." + f"equality against a non-None value — " + f"{_NON_DETERMINISTIC_EXPLANATION}" ) super().__init__(lhs, rhs) @@ -199,7 +206,7 @@ def _lookup_unsupported_message(self, method: str) -> str: ) return ( f"Encrypted field {self.name!r} does not support .{method}() — " - "ciphertext is non-deterministic. Use .is_null() instead." + f"{_NON_DETERMINISTIC_EXPLANATION}" ) def _check_encrypted_constraints(self) -> list[PreflightResult]: diff --git a/plain-postgres/plain/postgres/fields/related_typed.py b/plain-postgres/plain/postgres/fields/related_typed.py index e3c4bff700..cb518e40e8 100644 --- a/plain-postgres/plain/postgres/fields/related_typed.py +++ b/plain-postgres/plain/postgres/fields/related_typed.py @@ -10,163 +10,129 @@ from __future__ import annotations -import inspect from typing import TYPE_CHECKING, Any +from plain.postgres.exceptions import FieldDoesNotExist +from plain.postgres.fields.related import ForeignKeyField from plain.postgres.query_utils import Q if TYPE_CHECKING: from plain.postgres.base import Model + from plain.postgres.fields.base import Field + + +# Condition-method names a traversed field exposes. Traversal offers exactly the +# surface the field itself offers: an attribute outside this set is not a +# condition method and raises AttributeError rather than silently building a +# wrong-shaped Q. +_CONDITION_METHODS = frozenset( + { + "equals", + "not_equal", + "gt", + "gte", + "lt", + "lte", + "is_null", + "is_in", + "contains", + "icontains", + "startswith", + "endswith", + } +) + + +def _prefix_q(q: Q, parent_path: str) -> Q: + """Prepend `parent_path__` to every leaf lookup key in a Q tree, in place. + + A leaf child is a `(key, value)` tuple; a nested Q node is recursed into. + Negation and connector are left untouched — only the lookup keys change, + turning a Q built against a field's bare name into one whose keys carry the + full relation path. + """ + for i, child in enumerate(q.children): + if isinstance(child, Q): + _prefix_q(child, parent_path) + else: + key, value = child + q.children[i] = (f"{parent_path}__{key}", value) + return q class RelatedFieldRef: - """Class-level proxy that walks attribute access into the related model - and accumulates the lookup path prefix as it goes. - - Returned by `ForwardForeignKeyDescriptor.__get__` for the first hop; - chained traversal (`Order.user.profile.city`) builds nested - `RelatedFieldRef` instances until a concrete field is reached. - - The proxy's own namespace holds only traversal machinery, so a related - field whose name collides with a public attribute on the FK descriptor - (`field`, `is_cached`, `get_queryset`, …) still resolves to that field. - Attribute lookup reads the related model with `inspect.getattr_static`, - which returns the raw field/descriptor without invoking its `__get__`. + """Class-level proxy that walks attribute access into the related model and + accumulates the lookup-path prefix as it goes. + + Returned by `ForwardForeignKeyDescriptor.__get__` for the first hop; chained + traversal (`Order.user.profile.city`) builds nested `RelatedFieldRef` + instances until a concrete field is reached, then a `PrefixedFieldRef`. + + Names resolve through the related model's metadata (`get_forward_field`), + not attribute lookup, so a related field whose name collides with a public + attribute on the FK descriptor (`field`, `is_cached`, `get_queryset`, …) + still resolves to that field. """ - def __init__(self, model: type[Model] | str, prefix: str) -> None: + def __init__(self, model: type[Model], prefix: str) -> None: + assert not isinstance(model, str), ( + "RelatedFieldRef requires a resolved model class; the FK's " + "remote_field.model is replaced with the class at registration." + ) self._model = model self._prefix = prefix def __repr__(self) -> str: - name = self._model if isinstance(self._model, str) else self._model.__name__ - return f"" + return f"" def __getattr__(self, name: str) -> Any: if name.startswith("_"): # Avoid infinite recursion on internals and let pickling/hasattr # checks fail cleanly. raise AttributeError(name) - if isinstance(self._model, str): - # Relation not yet resolved (still a lazy string ref). Fail - # loudly rather than silently producing wrong-shaped queries. - raise AttributeError( - f"Cannot traverse {self._prefix}.{name}: relation is " - "still a string reference; the related model has not " - "been registered yet." - ) - from plain.postgres.fields.base import Field - from plain.postgres.fields.related_descriptors import ( - ForwardForeignKeyDescriptor, - ) try: - attr = inspect.getattr_static(self._model, name) - except AttributeError: - raise AttributeError(name) from None - - next_prefix = f"{self._prefix}__{name}" - if isinstance(attr, Field): - return PrefixedFieldRef(field=attr, prefix=next_prefix) - if isinstance(attr, ForwardForeignKeyDescriptor): + field = self._model._model_meta.get_forward_field(name) + except FieldDoesNotExist: + raise AttributeError( + f"{self._prefix}.{name} is not a traversable field or relation" + ) from None + + if isinstance(field, ForeignKeyField): return RelatedFieldRef( - model=attr.field.remote_field.model, prefix=next_prefix + model=field.remote_field.model, prefix=f"{self._prefix}__{name}" ) - raise AttributeError( - f"{self._prefix}.{name} is not a traversable field or relation" - ) + return PrefixedFieldRef(field=field, parent_path=self._prefix) class PrefixedFieldRef: - """A field-like reference that produces Q objects with a multi-segment - lookup path. Mirrors the typed-query method surface of `Field` and - `TextField` so chained access reads identically to direct access: - - Order.user.email.equals("x") # PrefixedFieldRef("user__email") - Order.email.equals("x") # Field/TextField on Order + """A field-like reference that rewrites the wrapped field's own Q conditions + onto a multi-segment lookup path, so chained access reads identically to + direct access: + + Order.user.email.equals("x") # PrefixedFieldRef(email_field, "user") + Order.email.equals("x") # TextField on Order + + A condition call delegates to the wrapped field's own method (which builds a + Q against the field's bare name, or raises — e.g. an encrypted field), then + prefixes every leaf key in that Q with the parent relation path. The + traversed surface is therefore exactly the field's own surface: a method the + field doesn't define raises AttributeError, same as direct access. """ - def __init__(self, field: Any, prefix: str) -> None: + def __init__(self, field: Field, parent_path: str) -> None: self._field = field - self._prefix = prefix + self._parent_path = parent_path def __repr__(self) -> str: - return f"" - - # Mirror Field[T] typed-query methods. Lookup suffixes match the strings - # the base Field methods produce via _build_q, so SQL resolution is the - # same as for a direct field reference. - def equals(self, value: Any) -> Q: - self._reject_if_blocked("equals") - return self._q("", value) - - def not_equal(self, value: Any) -> Q: - self._reject_if_blocked("not_equal") - return ~self._q("", value) - - def gt(self, value: Any) -> Q: - self._reject_if_blocked("gt") - return self._q("gt", value) - - def gte(self, value: Any) -> Q: - self._reject_if_blocked("gte") - return self._q("gte", value) - - def lt(self, value: Any) -> Q: - self._reject_if_blocked("lt") - return self._q("lt", value) - - def lte(self, value: Any) -> Q: - self._reject_if_blocked("lte") - return self._q("lte", value) - - def is_null(self, value: bool = True) -> Q: - return self._q("isnull", value) - - def is_in(self, values: Any) -> Q: - self._reject_if_blocked("is_in") - return self._q("in", values) - - # TextField-specific lookups — always exposed at the proxy layer because - # callers go through the typing lie (`Order.user.email` reads as - # TextField[str] to the type checker). At runtime, calling .contains on - # a non-text field's PrefixedFieldRef would build SQL that errors at - # query time, which is the same failure mode as a manual - # `filter(user__priority__contains=...)`. - def contains(self, value: str) -> Q: - self._reject_if_blocked("contains") - return self._q("contains", value) - - def icontains(self, value: str) -> Q: - self._reject_if_blocked("icontains") - return self._q("icontains", value) - - def startswith(self, value: str) -> Q: - self._reject_if_blocked("startswith") - return self._q("startswith", value) - - def endswith(self, value: str) -> Q: - self._reject_if_blocked("endswith") - return self._q("endswith", value) - - def _q(self, suffix: str, value: Any) -> Q: - key = f"{self._prefix}__{suffix}" if suffix else self._prefix - q = Q() - q.children.append((key, value)) - return q - - def _reject_if_blocked(self, method_name: str) -> None: - """Forward the typed-query block from fields that reject value - comparisons (currently EncryptedFieldMixin). Direct access raises - TypeError at the call site; without this hook, traversing through - a relation (Order.user.api_token.equals(...)) would silently build - a Q that only errors later at SQL build time.""" - from plain.postgres.fields.encrypted import EncryptedFieldMixin - - if isinstance(self._field, EncryptedFieldMixin): - field_name = getattr(self._field, "name", None) or "" - raise TypeError( - f"Encrypted field {field_name!r} (reached via " - f"{self._prefix!r}) does not support .{method_name}() — " - "ciphertext is non-deterministic. Use .is_null() instead." - ) + return f"" + + def __getattr__(self, name: str) -> Any: + if name not in _CONDITION_METHODS: + raise AttributeError(name) + field_method = getattr(self._field, name) + + def build(*args: Any, **kwargs: Any) -> Q: + return _prefix_q(field_method(*args, **kwargs), self._parent_path) + + return build diff --git a/plain-postgres/tests/public/test_typed_where_fk.py b/plain-postgres/tests/public/test_typed_where_fk.py index 9549724411..e33b98c50c 100644 --- a/plain-postgres/tests/public/test_typed_where_fk.py +++ b/plain-postgres/tests/public/test_typed_where_fk.py @@ -166,8 +166,8 @@ def test_traversed_equals_raises(self, db): from plain.postgres.fields.related_typed import PrefixedFieldRef ref = PrefixedFieldRef( - field=SecretStore._model_meta.get_field("api_key"), - prefix="store__api_key", + field=SecretStore._model_meta.get_forward_field("api_key"), + parent_path="store", ) with pytest.raises( TypeError, match=r"Encrypted field.*does not support \.equals\(" @@ -178,19 +178,32 @@ def test_traversed_ordering_raises(self): from plain.postgres.fields.related_typed import PrefixedFieldRef ref = PrefixedFieldRef( - field=SecretStore._model_meta.get_field("api_key"), - prefix="store__api_key", + field=SecretStore._model_meta.get_forward_field("api_key"), + parent_path="store", ) - for method in ("not_equal", "gt", "gte", "lt", "lte", "contains"): + for method in ("not_equal", "gt", "gte", "lt", "lte"): with pytest.raises(TypeError, match=rf"does not support \.{method}\("): getattr(ref, method)("x") + def test_traversed_text_method_absent_raises_attribute_error(self): + # Traversal exposes exactly the field's own surface. An encrypted field + # never defines .contains(), so traversing to it raises AttributeError, + # matching direct access (SecretStore.api_key.contains would too). + from plain.postgres.fields.related_typed import PrefixedFieldRef + + ref = PrefixedFieldRef( + field=SecretStore._model_meta.get_forward_field("api_key"), + parent_path="store", + ) + with pytest.raises(AttributeError): + ref.contains("x") + def test_traversed_is_in_raises(self): from plain.postgres.fields.related_typed import PrefixedFieldRef ref = PrefixedFieldRef( - field=SecretStore._model_meta.get_field("api_key"), - prefix="store__api_key", + field=SecretStore._model_meta.get_forward_field("api_key"), + parent_path="store", ) with pytest.raises(TypeError, match=r"does not support \.is_in\("): ref.is_in(["x", "y"]) @@ -199,13 +212,28 @@ def test_traversed_is_null_still_works(self): from plain.postgres.fields.related_typed import PrefixedFieldRef ref = PrefixedFieldRef( - field=SecretStore._model_meta.get_field("api_key"), - prefix="store__api_key", + field=SecretStore._model_meta.get_forward_field("api_key"), + parent_path="store", ) q = ref.is_null() assert q.children == [("store__api_key__isnull", True)] +def test_traversed_surface_matches_direct_field_surface(): + """Traversal exposes exactly the field's own condition surface. A text-only + method (.contains) is present when traversing to a TextField and absent when + traversing to a non-text field — the same as direct field access.""" + from plain.postgres.fields.related_typed import _CONDITION_METHODS + + for field_name in ("name", "id"): + direct = DeleteParent._model_meta.get_forward_field(field_name) + traversed = getattr(ChildCascade.parent, field_name) + for method in _CONDITION_METHODS: + assert hasattr(traversed, method) == hasattr(direct, method), ( + f"{field_name}.{method}" + ) + + # --------------------------------------------------------------------------- # Descriptor attribute shadowing: a related field whose name collides with a # public attribute on ForwardForeignKeyDescriptor (`field`, `is_cached`, From af0eb979e50cfbd2954d7b6d37271bd038c686f5 Mon Sep 17 00:00:00 2001 From: Dave Gaeddert Date: Fri, 18 Sep 2026 14:43:54 -0500 Subject: [PATCH 11/21] Fix typed where() against master: new lint rules, ty overrides, encrypted text surface Master's ruff 0.16 defaults and ty 0.0.80 landed after this branch: convert the shadowing migration to tuples, bind the AttributeError probe, and suppress the Liskov diagnostics the Never-typed blocks exist to cause. Master reparented EncryptedTextField onto TextField, so it now inherits contains/icontains/startswith/endswith - block those the same way, and keep the non-None exact rejection inside master's get_lookups() registry. Master's migrations-reset tests pinned the examples leaf by name; derive the leaf, the next number, and the leaf's models from the real history so adding a migration to examples doesn't break them. --- plain-postgres/plain/postgres/README.md | 4 +- .../plain/postgres/fields/encrypted.py | 29 +++++++++- .../0019_shadowtarget_shadowsource.py | 11 ++-- .../tests/app/examples/models/shadowing.py | 3 +- .../tests/internal/test_migrations_reset.py | 56 +++++++++++-------- .../tests/public/test_encrypted_fields.py | 9 +++ .../tests/public/test_typed_where.py | 1 - .../tests/public/test_typed_where_fk.py | 17 +++--- 8 files changed, 89 insertions(+), 41 deletions(-) diff --git a/plain-postgres/plain/postgres/README.md b/plain-postgres/plain/postgres/README.md index e7198b05b0..659359dd1a 100644 --- a/plain-postgres/plain/postgres/README.md +++ b/plain-postgres/plain/postgres/README.md @@ -185,6 +185,7 @@ For more advanced querying options, see the [`QuerySet`](./query.py#QuerySet) cl ```python from plain.postgres import types + @postgres.register_model class User(postgres.Model): email: str = types.EmailField() @@ -193,6 +194,7 @@ class User(postgres.Model): query: postgres.QuerySet[User] = postgres.QuerySet() + # Each argument is a condition; multiple arguments are ANDed together. admins = User.query.where( User.role.equals("admin"), @@ -1007,7 +1009,7 @@ Values are encrypted using Fernet (AES-128-CBC + HMAC-SHA256) with a key derived **Limitations:** -- **No lookups** — encrypted values are non-deterministic (same plaintext produces different ciphertext each time), so filtering on encrypted fields doesn't work. Only `isnull` lookups are supported. +- **No lookups** — encrypted values are non-deterministic (same plaintext produces different ciphertext each time), so filtering on encrypted fields doesn't work. Only `isnull` lookups are supported. Comparing against a value raises `TypeError` rather than silently matching nothing — both `filter(api_key="x")` and the typed [condition methods](#typed-conditions-with-where) (`equals`, `contains`, …). `filter(api_key=None)` still rewrites to `IS NULL`. - **No indexes or constraints** — encrypted fields cannot be used in indexes or unique constraints. Preflight checks will catch this. - **Only `default=""`** — on `EncryptedTextField` (paired with `required=False`), the empty string is stored as plaintext `''`, so it's the one value expressible as a column `DEFAULT` (declare it to add the field to a populated table). Any other default would need ciphertext, which is non-deterministic. `EncryptedJSONField` accepts no default at all — even `{}` serializes to text that would need ciphertext; use `allow_null=True`. diff --git a/plain-postgres/plain/postgres/fields/encrypted.py b/plain-postgres/plain/postgres/fields/encrypted.py index edc3889e06..c7dc448fa1 100644 --- a/plain-postgres/plain/postgres/fields/encrypted.py +++ b/plain-postgres/plain/postgres/fields/encrypted.py @@ -257,7 +257,12 @@ def _check_encrypted_constraints(self) -> list[PreflightResult]: return errors -class EncryptedTextField[T: (str, str | None) = str](EncryptedFieldMixin, TextField[T]): +# The mixin narrows Field's typed-query comparison methods to `Never` on +# purpose — that narrowing is the type-level block, and it is exactly what a +# Liskov check objects to, so the override diagnostic is suppressed here. +class EncryptedTextField[T: (str, str | None) = str]( # ty: ignore[invalid-method-override] + EncryptedFieldMixin, TextField[T] +): """A TextField that encrypts its value before storing in the database. Values are encrypted using Fernet (AES-128-CBC + HMAC-SHA256) with a key @@ -293,6 +298,23 @@ def __init__( validators=validators, ) + # TextField's pattern conditions arrive with the base class and are as + # meaningless on ciphertext as the comparisons the mixin blocks, so they + # are blocked here, where they arrive. Same shape as the mixin's blocks: + # `Never` rejects the call site, the raise covers anyone who bypasses the + # type checker. + def contains(self, value: Never) -> Never: # ty: ignore[invalid-method-override] + raise TypeError(self._lookup_unsupported_message("contains")) + + def icontains(self, value: Never) -> Never: # ty: ignore[invalid-method-override] + raise TypeError(self._lookup_unsupported_message("icontains")) + + def startswith(self, value: Never) -> Never: # ty: ignore[invalid-method-override] + raise TypeError(self._lookup_unsupported_message("startswith")) + + def endswith(self, value: Never) -> Never: # ty: ignore[invalid-method-override] + raise TypeError(self._lookup_unsupported_message("endswith")) + def get_db_prep_value( self, value: Any, connection: DatabaseConnection, prepared: bool = False ) -> Any: @@ -309,7 +331,10 @@ def from_db_value( return _decrypt(value) -class EncryptedJSONField(EncryptedFieldMixin, JSONField): +# The mixin narrows Field's typed-query comparison methods to `Never` on +# purpose — that narrowing is the type-level block, and it is exactly what a +# Liskov check objects to, so the override diagnostic is suppressed here. +class EncryptedJSONField(EncryptedFieldMixin, JSONField): # ty: ignore[invalid-method-override] """A JSONField that encrypts its serialized value before storing in the database. The JSON value is serialized to a string, encrypted, and stored as text. diff --git a/plain-postgres/tests/app/examples/migrations/0019_shadowtarget_shadowsource.py b/plain-postgres/tests/app/examples/migrations/0019_shadowtarget_shadowsource.py index 99a8cb6cb5..b73b6ffc0a 100644 --- a/plain-postgres/tests/app/examples/migrations/0019_shadowtarget_shadowsource.py +++ b/plain-postgres/tests/app/examples/migrations/0019_shadowtarget_shadowsource.py @@ -1,15 +1,14 @@ # Generated by Plain 0.154.0 on 2026-07-23 15:32 -from plain import postgres from plain.postgres import migrations +from plain import postgres + class Migration(migrations.Migration): - dependencies = [ - ("examples", "0018_storageparametersexample"), - ] + dependencies = (("examples", "0018_storageparametersexample"),) - operations = [ + operations = ( migrations.CreateModel( name="ShadowTarget", fields=[ @@ -32,4 +31,4 @@ class Migration(migrations.Migration): ), ], ), - ] + ) diff --git a/plain-postgres/tests/app/examples/models/shadowing.py b/plain-postgres/tests/app/examples/models/shadowing.py index 137c5ae694..30e458b844 100644 --- a/plain-postgres/tests/app/examples/models/shadowing.py +++ b/plain-postgres/tests/app/examples/models/shadowing.py @@ -1,8 +1,9 @@ from __future__ import annotations -from plain import postgres from plain.postgres import types +from plain import postgres + @postgres.register_model class ShadowTarget(postgres.Model): diff --git a/plain-postgres/tests/internal/test_migrations_reset.py b/plain-postgres/tests/internal/test_migrations_reset.py index 96082af838..280c31e47e 100644 --- a/plain-postgres/tests/internal/test_migrations_reset.py +++ b/plain-postgres/tests/internal/test_migrations_reset.py @@ -3,11 +3,14 @@ `plan_reset` builds it, `validate_reset` proves the post-reset graph loads and reproduces the models; the CLI writes and deletes. These tests copy the real `examples` history into the temporary migrations root so a reset of it -is the reset of an eighteen-migration package with a circular FK inside. +is the reset of the whole shipped package, circular FK inside. The leaf, the +number a new migration would take, and the models the leaf creates are read +from that history so adding a migration to `examples` doesn't break these. """ from __future__ import annotations +import re import shutil import subprocess from collections.abc import Callable @@ -26,7 +29,15 @@ from plain.postgres.migrations.writer import MigrationWriter REAL_EXAMPLES = Path(__file__).parent.parent / "app" / "examples" / "migrations" -LEAF = "0018_storageparametersexample" +REAL_NAMES = sorted(p.stem for p in REAL_EXAMPLES.glob("0*.py")) +LEAF = REAL_NAMES[-1] +# The number a migration written on top of the copied history takes. +NEXT = f"{int(LEAF.split('_')[0]) + 1:04d}" +BASELINE = f"{NEXT}_baseline" +# The models the leaf creates - deleting it makes exactly these pending. +LEAF_MODELS = re.findall( + r'CreateModel\(\s*name="(\w+)"', (REAL_EXAMPLES / f"{LEAF}.py").read_text() +) def migration_source( @@ -69,7 +80,7 @@ def test_reset_of_the_examples_history(migrations_dir: Path) -> None: current = loader() plan = plan_reset(current, "examples") - assert plan.baseline.name == "0019_baseline" + assert plan.baseline.name == BASELINE assert plan.baseline.supersedes == LEAF assert plan.baseline.since == "" assert plan.baseline.initial is None @@ -116,7 +127,7 @@ def test_written_baseline_loads_clean_with_the_history_gone( path.unlink() after = loader() - assert after.baselines["examples"].name == "0019_baseline" + assert after.baselines["examples"].name == BASELINE assert detect_model_changes(after, {"examples"}) == {} @@ -262,7 +273,7 @@ def test_first_reset_cycle_is_refused_before_anything_changes( (migrations_dir / "plaintemplates" / "0001_initial.py").write_text( migration_source(operations=f"({create_model('Note')},)") ) - (migrations_dir / "examples" / "0019_gadget.py").write_text( + (migrations_dir / "examples" / f"{NEXT}_gadget.py").write_text( migration_source( dependencies=(("examples", LEAF), ("plaintemplates", "0001_initial")), operations=f"({create_model('Gadget')},)", @@ -272,7 +283,7 @@ def test_first_reset_cycle_is_refused_before_anything_changes( migration_source( dependencies=( ("plaintemplates", "0001_initial"), - ("examples", "0019_gadget"), + ("examples", f"{NEXT}_gadget"), ), operations='(migrations.AddField(model_name="note", name="gadget", field=postgres.ForeignKeyField(to="examples.gadget", on_delete=postgres.CASCADE, allow_null=True)),)', ) @@ -281,11 +292,11 @@ def test_first_reset_cycle_is_refused_before_anything_changes( current = loader() plan = plan_reset(current, "plaintemplates") - assert plan.baseline.dependencies == [("examples", "0019_gadget")] + assert plan.baseline.dependencies == [("examples", f"{NEXT}_gadget")] with pytest.raises(BadMigrationError) as excinfo: validate_reset(current, plan) # The cycle, reported from whichever node the search entered it. - assert "examples.0019_gadget" in str(excinfo.value) + assert f"examples.{NEXT}_gadget" in str(excinfo.value) assert "plaintemplates.0003_baseline" in str(excinfo.value) assert ( @@ -302,10 +313,10 @@ def test_nothing_to_reset(migrations_dir: Path) -> None: def test_two_leaves_refuse(migrations_dir: Path) -> None: - (migrations_dir / "examples" / "0019_a.py").write_text( + (migrations_dir / "examples" / f"{NEXT}_a.py").write_text( migration_source(dependencies=(("examples", LEAF),)) ) - (migrations_dir / "examples" / "0019_b.py").write_text( + (migrations_dir / "examples" / f"{NEXT}_b.py").write_text( migration_source(dependencies=(("examples", LEAF),)) ) with pytest.raises(BadMigrationError, match="2 leaf migrations"): @@ -342,22 +353,22 @@ def test_reset_command_writes_and_deletes_then_the_runtime_adopts( result = CliRunner().invoke(reset, ["examples", "--since", "2.0"]) assert result.exit_code == 0, result.output - assert "Supersedes 0018_storageparametersexample" in result.output + assert f"Supersedes {LEAF}" in result.output assert "Recover from any failure with: git checkout --" in result.output assert "&& rm " in result.output assert "Commit the new file and the deletions together" in result.output assert "`since` is empty" not in result.output - assert "must have applied `examples.0018_storageparametersexample`" in result.output - assert examples_files(migrations_dir) == ["0019_baseline.py"] - source = (migrations_dir / "examples" / "0019_baseline.py").read_text() - assert "supersedes = '0018_storageparametersexample'" in source + assert f"must have applied `examples.{LEAF}`" in result.output + assert examples_files(migrations_dir) == [f"{BASELINE}.py"] + source = (migrations_dir / "examples" / f"{BASELINE}.py").read_text() + assert f"supersedes = '{LEAF}'" in source assert "since = '2.0'" in source assert "initial = True" not in source # The test database recorded the sentinel: the runtime adopts. applied = CliRunner().invoke(apply, ["--no-input"]) assert applied.exit_code == 0, applied.output - assert "examples.0019_baseline (baseline: recorded, not run)" in applied.output + assert f"examples.{BASELINE} (baseline: recorded, not run)" in applied.output def test_generated_baseline_runs_on_a_cleared_database( @@ -407,14 +418,14 @@ def test_uncommitted_history_refuses(repo: Path, migrations_dir: Path) -> None: assert f"{LEAF}.py" in result.output git(repo, "checkout", "--", ".") - (migrations_dir / "examples" / "0019_new.py").write_text( + (migrations_dir / "examples" / f"{NEXT}_new.py").write_text( migration_source(dependencies=(("examples", LEAF),)) ) result = CliRunner().invoke(reset, ["examples"]) assert result.exit_code != 0 assert "Not tracked by git" in result.output - assert "0019_new.py" in result.output - assert examples_files(migrations_dir)[-1] == "0019_new.py" + assert f"{NEXT}_new.py" in result.output + assert examples_files(migrations_dir)[-1] == f"{NEXT}_new.py" def test_outside_a_repository_refuses(migrations_dir: Path) -> None: @@ -422,7 +433,7 @@ def test_outside_a_repository_refuses(migrations_dir: Path) -> None: assert result.exit_code != 0 assert "git could not read" in result.output assert "not a git repository" in result.output - assert len(examples_files(migrations_dir)) == 18 + assert len(examples_files(migrations_dir)) == len(REAL_NAMES) def test_pending_model_changes_refuse(migrations_dir: Path) -> None: @@ -436,8 +447,9 @@ def test_pending_model_changes_refuse(migrations_dir: Path) -> None: assert result.exit_code != 0 assert "model changes its migrations don't hold" in result.output - assert "Create model StorageParametersExample" in result.output - assert len(examples_files(migrations_dir)) == 17 + for model_name in LEAF_MODELS: + assert f"Create model {model_name}" in result.output + assert len(examples_files(migrations_dir)) == len(REAL_NAMES) - 1 def test_code_defined_in_a_deleted_migration_is_flagged(migrations_dir: Path) -> None: diff --git a/plain-postgres/tests/public/test_encrypted_fields.py b/plain-postgres/tests/public/test_encrypted_fields.py index e48b498e11..7d99dfcd4b 100644 --- a/plain-postgres/tests/public/test_encrypted_fields.py +++ b/plain-postgres/tests/public/test_encrypted_fields.py @@ -178,6 +178,15 @@ def test_is_in_raises(self): with pytest.raises(TypeError, match=r"does not support \.is_in\("): SecretStore.api_key.is_in(["x", "y"]) # ty: ignore[invalid-argument-type] + @pytest.mark.parametrize( + "method", ["contains", "icontains", "startswith", "endswith"] + ) + def test_text_pattern_condition_raises(self, method): + """EncryptedTextField inherits TextField's pattern conditions and + blocks them — matching ciphertext by substring is meaningless.""" + with pytest.raises(TypeError, match=rf"does not support \.{method}\("): + getattr(SecretStore.api_key, method)("x") + def test_is_null_returns_correct_lookup(self): """is_null is the one comparison that makes sense on ciphertext.""" from plain.postgres.query_utils import Q diff --git a/plain-postgres/tests/public/test_typed_where.py b/plain-postgres/tests/public/test_typed_where.py index afcb506fcf..be901e8be9 100644 --- a/plain-postgres/tests/public/test_typed_where.py +++ b/plain-postgres/tests/public/test_typed_where.py @@ -10,7 +10,6 @@ from typing import TYPE_CHECKING, assert_type from app.examples.models.defaults import DefaultsExample - from plain.postgres.fields.numeric import IntegerField from plain.postgres.fields.text import TextField from plain.postgres.query_utils import Q diff --git a/plain-postgres/tests/public/test_typed_where_fk.py b/plain-postgres/tests/public/test_typed_where_fk.py index e33b98c50c..2c11ff76a7 100644 --- a/plain-postgres/tests/public/test_typed_where_fk.py +++ b/plain-postgres/tests/public/test_typed_where_fk.py @@ -18,7 +18,6 @@ from app.examples.models.encrypted import SecretStore from app.examples.models.relationships import Tag, Widget, WidgetTag from app.examples.models.shadowing import ShadowSource, ShadowTarget - from plain.postgres.query_utils import Q @@ -100,7 +99,7 @@ def test_unknown_attribute_on_related_raises_attribute_error(): """Traversal into a non-existent field on the related model fails loudly, not silently producing a wrong-shaped Q.""" with pytest.raises(AttributeError): - ChildCascade.parent.nonexistent_field # ty: ignore[unresolved-attribute] + _ = ChildCascade.parent.nonexistent_field # ty: ignore[unresolved-attribute] # --------------------------------------------------------------------------- @@ -185,18 +184,20 @@ def test_traversed_ordering_raises(self): with pytest.raises(TypeError, match=rf"does not support \.{method}\("): getattr(ref, method)("x") - def test_traversed_text_method_absent_raises_attribute_error(self): - # Traversal exposes exactly the field's own surface. An encrypted field - # never defines .contains(), so traversing to it raises AttributeError, - # matching direct access (SecretStore.api_key.contains would too). + @pytest.mark.parametrize( + "method", ["contains", "icontains", "startswith", "endswith"] + ) + def test_traversed_text_method_raises(self, method): + # EncryptedTextField inherits TextField's pattern conditions and blocks + # them, so traversal reports the same TypeError direct access does. from plain.postgres.fields.related_typed import PrefixedFieldRef ref = PrefixedFieldRef( field=SecretStore._model_meta.get_forward_field("api_key"), parent_path="store", ) - with pytest.raises(AttributeError): - ref.contains("x") + with pytest.raises(TypeError, match=rf"does not support \.{method}\("): + getattr(ref, method)("x") def test_traversed_is_in_raises(self): from plain.postgres.fields.related_typed import PrefixedFieldRef From 69c757ec150c496715f4c40eb0c1d4bdde92f30a Mon Sep 17 00:00:00 2001 From: Dave Gaeddert Date: Fri, 18 Sep 2026 15:14:32 -0500 Subject: [PATCH 12/21] Anchor the reset test on the leaf migration's own models The pending-changes assertion read "Create model " with no name, which passes no matter what the refusal lists. Read the leaf migration's CreateModel operations instead, so deleting it has a named consequence and no branch has to re-pin the model by hand. --- .../tests/internal/test_migrations_reset.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/plain-postgres/tests/internal/test_migrations_reset.py b/plain-postgres/tests/internal/test_migrations_reset.py index 7aaaaf1cc5..6981bbb21f 100644 --- a/plain-postgres/tests/internal/test_migrations_reset.py +++ b/plain-postgres/tests/internal/test_migrations_reset.py @@ -10,7 +10,7 @@ from __future__ import annotations -import re +import importlib import shutil import subprocess from collections.abc import Callable @@ -34,10 +34,15 @@ # The number a migration written on top of the copied history takes. NEXT = f"{int(LEAF.split('_')[0]) + 1:04d}" BASELINE = f"{NEXT}_baseline" -# The models the leaf creates - deleting it makes exactly these pending. -LEAF_MODELS = re.findall( - r'CreateModel\(\s*name="(\w+)"', (REAL_EXAMPLES / f"{LEAF}.py").read_text() -) +# The models the leaf migration creates, so deleting it has a known consequence. +LEAF_CREATED_MODELS = [ + operation.name + for operation in importlib.import_module( + f"app.examples.migrations.{LEAF}" + ).Migration.operations + if isinstance(operation, operations.CreateModel) +] +assert LEAF_CREATED_MODELS, f"{LEAF} creates no models, so this file needs a new anchor" def migration_source( @@ -447,7 +452,7 @@ def test_pending_model_changes_refuse(migrations_dir: Path) -> None: assert result.exit_code != 0 assert "model changes its migrations don't hold" in result.output - for model_name in LEAF_MODELS: + for model_name in LEAF_CREATED_MODELS: assert f"Create model {model_name}" in result.output assert len(examples_files(migrations_dir)) == len(REAL_NAMES) - 1 From e0c3c7fd9532a98761163c0b360a5c027d2f459b Mon Sep 17 00:00:00 2001 From: Dave Gaeddert Date: Fri, 18 Sep 2026 15:29:05 -0500 Subject: [PATCH 13/21] Pin the encrypted-field block from the type checker's side The ty: ignore[invalid-method-override] on EncryptedTextField and EncryptedJSONField is class-wide, so it would also hide a block that stopped blocking. Each blocked method now has a direct call site carrying ty: ignore[invalid-argument-type] - ty reports an unused suppression as an error, so widening any parameter away from Never fails type-check - and the same calls assert the runtime TypeError, pinning both sides together. is_null() carries no marker, so a Never creeping onto it breaks the build. Note on each class-level ignore that it exists for the deliberate Never narrowing and that any other override on the class needs a hand check. --- .../plain/postgres/fields/encrypted.py | 4 ++ .../tests/public/test_encrypted_fields.py | 56 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/plain-postgres/plain/postgres/fields/encrypted.py b/plain-postgres/plain/postgres/fields/encrypted.py index c7dc448fa1..67ed8eaf0a 100644 --- a/plain-postgres/plain/postgres/fields/encrypted.py +++ b/plain-postgres/plain/postgres/fields/encrypted.py @@ -260,6 +260,8 @@ def _check_encrypted_constraints(self) -> list[PreflightResult]: # The mixin narrows Field's typed-query comparison methods to `Never` on # purpose — that narrowing is the type-level block, and it is exactly what a # Liskov check objects to, so the override diagnostic is suppressed here. +# The suppression is class-wide (ty reports the conflict at the class line), so +# any OTHER override added to this class has to be checked by hand. class EncryptedTextField[T: (str, str | None) = str]( # ty: ignore[invalid-method-override] EncryptedFieldMixin, TextField[T] ): @@ -334,6 +336,8 @@ def from_db_value( # The mixin narrows Field's typed-query comparison methods to `Never` on # purpose — that narrowing is the type-level block, and it is exactly what a # Liskov check objects to, so the override diagnostic is suppressed here. +# The suppression is class-wide (ty reports the conflict at the class line), so +# any OTHER override added to this class has to be checked by hand. class EncryptedJSONField(EncryptedFieldMixin, JSONField): # ty: ignore[invalid-method-override] """A JSONField that encrypts its serialized value before storing in the database. diff --git a/plain-postgres/tests/public/test_encrypted_fields.py b/plain-postgres/tests/public/test_encrypted_fields.py index 7d99dfcd4b..9c446053e9 100644 --- a/plain-postgres/tests/public/test_encrypted_fields.py +++ b/plain-postgres/tests/public/test_encrypted_fields.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import assert_type + import pytest from app.examples.models.encrypted import SecretStore from plain.postgres.exceptions import FieldError @@ -9,6 +11,7 @@ _encrypt, _get_fernet, ) +from plain.postgres.query_utils import Q class TestEncryptDecryptFunctions: @@ -187,6 +190,59 @@ def test_text_pattern_condition_raises(self, method): with pytest.raises(TypeError, match=rf"does not support \.{method}\("): getattr(SecretStore.api_key, method)("x") + def test_every_blocked_method_is_rejected_statically(self): + """Pin the block from the type checker's side, not just the runtime's. + + Each `ty: ignore[invalid-argument-type]` below asserts that the call + is a type error: ty reports an unused suppression as an error of its + own, so if any of these parameters ever widens away from `Never`, + `./scripts/type-check plain-postgres` fails here. That matters because + the `ty: ignore[invalid-method-override]` on EncryptedTextField is + class-wide and would otherwise hide a block that stopped blocking. + + The same calls are asserted to raise, so the runtime and the type + checker are pinned to each other in one place. + """ + with pytest.raises(TypeError): + SecretStore.api_key.equals("x") # ty: ignore[invalid-argument-type] + with pytest.raises(TypeError): + SecretStore.api_key.not_equal("x") # ty: ignore[invalid-argument-type] + with pytest.raises(TypeError): + SecretStore.api_key.gt("x") # ty: ignore[invalid-argument-type] + with pytest.raises(TypeError): + SecretStore.api_key.gte("x") # ty: ignore[invalid-argument-type] + with pytest.raises(TypeError): + SecretStore.api_key.lt("x") # ty: ignore[invalid-argument-type] + with pytest.raises(TypeError): + SecretStore.api_key.lte("x") # ty: ignore[invalid-argument-type] + with pytest.raises(TypeError): + SecretStore.api_key.is_in(["x"]) # ty: ignore[invalid-argument-type] + with pytest.raises(TypeError): + SecretStore.api_key.contains("x") # ty: ignore[invalid-argument-type] + with pytest.raises(TypeError): + SecretStore.api_key.icontains("x") # ty: ignore[invalid-argument-type] + with pytest.raises(TypeError): + SecretStore.api_key.startswith("x") # ty: ignore[invalid-argument-type] + with pytest.raises(TypeError): + SecretStore.api_key.endswith("x") # ty: ignore[invalid-argument-type] + + def test_is_null_survives_the_block_statically(self): + """The one condition that stays open must keep its real signature - + no ignore marker here, so a `Never` creeping onto is_null breaks the + build.""" + assert_type(SecretStore.api_key.is_null(), Q) + assert_type(SecretStore.api_key.is_null(False), Q) + + @pytest.mark.parametrize( + "method", ["equals", "not_equal", "gt", "gte", "lt", "lte", "is_in"] + ) + def test_json_field_comparison_raises(self, method): + """EncryptedJSONField carries the mixin's block too. Only the runtime + side is assertable here - the model annotates `config` as `dict | None`, + so class access doesn't type as the field.""" + with pytest.raises(TypeError, match=rf"does not support \.{method}\("): + getattr(SecretStore.config, method)("x") + def test_is_null_returns_correct_lookup(self): """is_null is the one comparison that makes sense on ciphertext.""" from plain.postgres.query_utils import Q From 6e0bbd1cb5c9e9264be849f770a11cb3e98663a3 Mon Sep 17 00:00:00 2001 From: Dave Gaeddert Date: Fri, 18 Sep 2026 15:34:57 -0500 Subject: [PATCH 14/21] Converge the reset test on the master-bound version --- .../tests/internal/test_migrations_reset.py | 50 ++++++++++--------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/plain-postgres/tests/internal/test_migrations_reset.py b/plain-postgres/tests/internal/test_migrations_reset.py index 6981bbb21f..024a820a03 100644 --- a/plain-postgres/tests/internal/test_migrations_reset.py +++ b/plain-postgres/tests/internal/test_migrations_reset.py @@ -3,9 +3,7 @@ `plan_reset` builds it, `validate_reset` proves the post-reset graph loads and reproduces the models; the CLI writes and deletes. These tests copy the real `examples` history into the temporary migrations root so a reset of it -is the reset of the whole shipped package, circular FK inside. The leaf, the -number a new migration would take, and the models the leaf creates are read -from that history so adding a migration to `examples` doesn't break these. +is the reset of the whole package, circular FK inside. """ from __future__ import annotations @@ -29,11 +27,12 @@ from plain.postgres.migrations.writer import MigrationWriter REAL_EXAMPLES = Path(__file__).parent.parent / "app" / "examples" / "migrations" -REAL_NAMES = sorted(p.stem for p in REAL_EXAMPLES.glob("0*.py")) -LEAF = REAL_NAMES[-1] -# The number a migration written on top of the copied history takes. -NEXT = f"{int(LEAF.split('_')[0]) + 1:04d}" -BASELINE = f"{NEXT}_baseline" +# Read the real history rather than pinning names, so adding a migration to the +# examples app doesn't break every assertion below. +EXAMPLES_NAMES = sorted(path.stem for path in REAL_EXAMPLES.glob("0*.py")) +EXAMPLES_COUNT = len(EXAMPLES_NAMES) +LEAF = EXAMPLES_NAMES[-1] +NEXT_NUMBER = f"{EXAMPLES_COUNT + 1:04d}" # The models the leaf migration creates, so deleting it has a known consequence. LEAF_CREATED_MODELS = [ operation.name @@ -85,7 +84,7 @@ def test_reset_of_the_examples_history(migrations_dir: Path) -> None: current = loader() plan = plan_reset(current, "examples") - assert plan.baseline.name == BASELINE + assert plan.baseline.name == f"{NEXT_NUMBER}_baseline" assert plan.baseline.supersedes == LEAF assert plan.baseline.shipped_in == "" assert plan.baseline.initial is None @@ -132,7 +131,7 @@ def test_written_baseline_loads_clean_with_the_history_gone( path.unlink() after = loader() - assert after.baselines["examples"].name == BASELINE + assert after.baselines["examples"].name == f"{NEXT_NUMBER}_baseline" assert detect_model_changes(after, package_labels={"examples"}) == {} @@ -278,7 +277,7 @@ def test_first_reset_cycle_is_refused_before_anything_changes( (migrations_dir / "plaintemplates" / "0001_initial.py").write_text( migration_source(operations=f"({create_model('Note')},)") ) - (migrations_dir / "examples" / f"{NEXT}_gadget.py").write_text( + (migrations_dir / "examples" / f"{NEXT_NUMBER}_gadget.py").write_text( migration_source( dependencies=(("examples", LEAF), ("plaintemplates", "0001_initial")), operations=f"({create_model('Gadget')},)", @@ -288,7 +287,7 @@ def test_first_reset_cycle_is_refused_before_anything_changes( migration_source( dependencies=( ("plaintemplates", "0001_initial"), - ("examples", f"{NEXT}_gadget"), + ("examples", f"{NEXT_NUMBER}_gadget"), ), operations='(migrations.AddField(model_name="note", name="gadget", field=postgres.ForeignKeyField(to="examples.gadget", on_delete=postgres.CASCADE, allow_null=True)),)', ) @@ -297,11 +296,11 @@ def test_first_reset_cycle_is_refused_before_anything_changes( current = loader() plan = plan_reset(current, "plaintemplates") - assert plan.baseline.dependencies == [("examples", f"{NEXT}_gadget")] + assert plan.baseline.dependencies == [("examples", f"{NEXT_NUMBER}_gadget")] with pytest.raises(BadMigrationError) as excinfo: validate_reset(current, plan) # The cycle, reported from whichever node the search entered it. - assert f"examples.{NEXT}_gadget" in str(excinfo.value) + assert f"examples.{NEXT_NUMBER}_gadget" in str(excinfo.value) assert "plaintemplates.0003_baseline" in str(excinfo.value) assert ( @@ -318,10 +317,10 @@ def test_nothing_to_reset(migrations_dir: Path) -> None: def test_two_leaves_refuse(migrations_dir: Path) -> None: - (migrations_dir / "examples" / f"{NEXT}_a.py").write_text( + (migrations_dir / "examples" / f"{NEXT_NUMBER}_a.py").write_text( migration_source(dependencies=(("examples", LEAF),)) ) - (migrations_dir / "examples" / f"{NEXT}_b.py").write_text( + (migrations_dir / "examples" / f"{NEXT_NUMBER}_b.py").write_text( migration_source(dependencies=(("examples", LEAF),)) ) with pytest.raises(BadMigrationError, match="2 leaf migrations"): @@ -364,8 +363,8 @@ def test_reset_command_writes_and_deletes_then_the_runtime_adopts( assert "Commit the new file and the deletions together" in result.output assert "`shipped_in` is empty" not in result.output assert f"must have applied `examples.{LEAF}`" in result.output - assert examples_files(migrations_dir) == [f"{BASELINE}.py"] - source = (migrations_dir / "examples" / f"{BASELINE}.py").read_text() + assert examples_files(migrations_dir) == [f"{NEXT_NUMBER}_baseline.py"] + source = (migrations_dir / "examples" / f"{NEXT_NUMBER}_baseline.py").read_text() assert f"supersedes = '{LEAF}'" in source assert "shipped_in = '2.0'" in source assert "initial = True" not in source @@ -373,7 +372,10 @@ def test_reset_command_writes_and_deletes_then_the_runtime_adopts( # The test database recorded the sentinel: the runtime adopts. applied = CliRunner().invoke(apply, ["--no-input"]) assert applied.exit_code == 0, applied.output - assert f"examples.{BASELINE} (baseline: recorded, not run)" in applied.output + assert ( + f"examples.{NEXT_NUMBER}_baseline (baseline: recorded, not run)" + in applied.output + ) def test_generated_baseline_runs_on_a_cleared_database( @@ -423,14 +425,14 @@ def test_uncommitted_history_refuses(repo: Path, migrations_dir: Path) -> None: assert f"{LEAF}.py" in result.output git(repo, "checkout", "--", ".") - (migrations_dir / "examples" / f"{NEXT}_new.py").write_text( + (migrations_dir / "examples" / f"{NEXT_NUMBER}_new.py").write_text( migration_source(dependencies=(("examples", LEAF),)) ) result = CliRunner().invoke(reset, ["examples"]) assert result.exit_code != 0 assert "Not tracked by git" in result.output - assert f"{NEXT}_new.py" in result.output - assert examples_files(migrations_dir)[-1] == f"{NEXT}_new.py" + assert f"{NEXT_NUMBER}_new.py" in result.output + assert examples_files(migrations_dir)[-1] == f"{NEXT_NUMBER}_new.py" def test_outside_a_repository_refuses(migrations_dir: Path) -> None: @@ -438,7 +440,7 @@ def test_outside_a_repository_refuses(migrations_dir: Path) -> None: assert result.exit_code != 0 assert "git could not read" in result.output assert "not a git repository" in result.output - assert len(examples_files(migrations_dir)) == len(REAL_NAMES) + assert len(examples_files(migrations_dir)) == EXAMPLES_COUNT def test_pending_model_changes_refuse(migrations_dir: Path) -> None: @@ -454,7 +456,7 @@ def test_pending_model_changes_refuse(migrations_dir: Path) -> None: assert "model changes its migrations don't hold" in result.output for model_name in LEAF_CREATED_MODELS: assert f"Create model {model_name}" in result.output - assert len(examples_files(migrations_dir)) == len(REAL_NAMES) - 1 + assert len(examples_files(migrations_dir)) == EXAMPLES_COUNT - 1 def test_code_defined_in_a_deleted_migration_is_flagged(migrations_dir: Path) -> None: From dd38bf1f826a4880aea9f859644f644e8358626e Mon Sep 17 00:00:00 2001 From: Dave Gaeddert Date: Fri, 18 Sep 2026 21:09:10 -0500 Subject: [PATCH 15/21] Carry the typed query surface through #83's Field[T] annotations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #83 annotates every model field with the *base* `Field[T]`. A declared type is what the checker reads, so that annotation erases the concrete descriptor the stub returns — and with it everything typed where() had hung off the concrete class: TextField's pattern conditions, the FK descriptor's `type[T]` class access, and the encrypted fields' `Never` blocks. Put each back where the annotation can still see it. - `Field.__get__` gains two model-valued class-access overloads, so a field whose value type is a model (an FK, nullable or not) yields `type[T]` and the related model's field surface stays reachable for traversal. This is what makes `Model(fk=obj)` and `Model.fk.name.equals(...)` both type-check through the same `Field[Related]` annotation. `_ForeignKeyDescriptor` no longer overrides `__get__` at all — the base covers it, and an override there was both invisible behind the annotation and a Liskov error. - `Field` declares `contains`/`icontains`/`startswith`/`endswith` under TYPE_CHECKING with a `self: Field[str] | Field[str | None]` annotation. The implementations stay on TextField, so the runtime surface (and the traversal surface that mirrors it) is unchanged, while `Field[int].startswith(...)` is still a type error. - `EncryptedFieldMixin` becomes `EncryptedField[T]`, a real `Field[T]` subclass exported from `plain.postgres`, and encrypted model fields are annotated with it. It's the only way to keep the blocks visible: they can't live on `Field[T]` without blocking every field. The pattern-condition blocks move up from EncryptedTextField, so EncryptedJSONField carries them too. `assert_type(Model.name, ...)` now asserts `Field[str]` rather than `TextField[str]` — under #83 that is the declared type — and a new type-check-only case pins the string-only restriction. --- .claude/rules/plain-postgres.md | 10 +- plain-oauth/plain/oauth/models.py | 6 +- plain-postgres/plain/postgres/README.md | 16 ++- plain-postgres/plain/postgres/__init__.py | 5 + .../agents/.claude/rules/plain-postgres.md | 10 +- plain-postgres/plain/postgres/fields/base.py | 35 +++++++ .../plain/postgres/fields/encrypted.py | 97 ++++++++++--------- plain-postgres/plain/postgres/types.pyi | 34 ++++--- .../tests/app/examples/models/encrypted.py | 8 +- .../tests/public/test_typed_where.py | 27 +++++- 10 files changed, 170 insertions(+), 78 deletions(-) diff --git a/.claude/rules/plain-postgres.md b/.claude/rules/plain-postgres.md index 68b58103b1..f1ac1c6f66 100644 --- a/.claude/rules/plain-postgres.md +++ b/.claude/rules/plain-postgres.md @@ -46,9 +46,17 @@ class Article(postgres.Model): - **String forward-ref FKs** (`"self"`, `"OtherModel"`) keep a _value-type_ annotation — the checker can't resolve the string to a model: `parent: Foo | None = types.ForeignKeyField("self", on_delete=postgres.CASCADE, allow_null=True, default=None)`. + That annotation is a model instance, not a field, so `where()` traversal + (`Child.parent.name.equals(...)`) doesn't type-check through it — declare the + target above and pass the class when you want traversal typed. +- **Encrypted fields** are annotated `EncryptedField[T]` (imported from + `plain.postgres` alongside `Field`), not `Field[T]`. It's a `Field[T]` + subclass, so the constructor is typed identically, but it also carries the + `Never`-typed blocks that reject `Model.secret.equals(...)` at the call site. + A plain `Field[T]` hides them and the comparison only fails at runtime. - **JSON**: `JSONField`/`EncryptedJSONField` return `Any` from the stub (the runtime class isn't generic over its value shape), so the annotation is what - preserves typing: `Field[dict]` / `Field[dict[str, Any]]`. + preserves typing: `Field[dict]` / `EncryptedField[dict[str, Any]]`. - **Custom querysets**: declare `query: ClassVar[MyQuerySet] = MyQuerySet()` (`ClassVar` so it isn't treated as a field). Default-queryset models declare nothing — `Model.query` is typed automatically. diff --git a/plain-oauth/plain/oauth/models.py b/plain-oauth/plain/oauth/models.py index 643f16f435..71224d3e71 100644 --- a/plain-oauth/plain/oauth/models.py +++ b/plain-oauth/plain/oauth/models.py @@ -6,7 +6,7 @@ import psycopg from app.users.models import User from plain.exceptions import ValidationError -from plain.postgres import Field, transaction, types +from plain.postgres import EncryptedField, Field, transaction, types from plain.utils import timezone from plain import postgres @@ -36,8 +36,8 @@ class OAuthConnection(postgres.Model): provider_user_id: Field[str] = types.TextField(max_length=100) # Token data - access_token: Field[str] = types.EncryptedTextField(max_length=2000) - refresh_token: Field[str] = types.EncryptedTextField( + access_token: EncryptedField[str] = types.EncryptedTextField(max_length=2000) + refresh_token: EncryptedField[str] = types.EncryptedTextField( max_length=2000, required=False, default="" ) access_token_expires_at: Field[datetime | None] = types.DateTimeField( diff --git a/plain-postgres/plain/postgres/README.md b/plain-postgres/plain/postgres/README.md index f71845b092..b27d719064 100644 --- a/plain-postgres/plain/postgres/README.md +++ b/plain-postgres/plain/postgres/README.md @@ -1035,28 +1035,36 @@ This is **not** for passwords or tokens you issue — those should be hashed (on ```python from plain import postgres -from plain.postgres import Field, types +from plain.postgres import EncryptedField, Field, types @postgres.register_model class Integration(postgres.Model): name: Field[str] = types.TextField(max_length=100) - api_key: Field[str] = types.EncryptedTextField(max_length=200) - credentials: Field[dict | None] = types.EncryptedJSONField( + api_key: EncryptedField[str] = types.EncryptedTextField(max_length=200) + credentials: EncryptedField[dict | None] = types.EncryptedJSONField( required=False, allow_null=True, default=None ) ``` +Annotate encrypted fields `EncryptedField[T]`, not `Field[T]`. The annotation is +what the type checker reads, and `EncryptedField[T]` is the `Field[T]` subclass +that declares the blocked conditions — with a plain `Field[T]`, +`Integration.api_key.equals("x")` type-checks its way to a runtime `TypeError` +instead of being rejected at the call site. It types the constructor exactly as +`Field[T]` does. + Values are encrypted using Fernet (AES-128-CBC + HMAC-SHA256) with a key derived from `SECRET_KEY`. The `cryptography` package is required — install it with `pip install cryptography`. **Available fields:** +- `EncryptedField[T]` — the annotation type; also the shared base the two fields below derive from. - `EncryptedTextField` — encrypts text, stored as `text` in the database regardless of `max_length` (ciphertext is longer than plaintext). `max_length` is enforced on the plaintext value during validation. - `EncryptedJSONField` — serializes to JSON, encrypts, and stores as `text`. Supports custom `encoder` and `decoder` parameters (same as `JSONField`). **Limitations:** -- **No lookups** — encrypted values are non-deterministic (same plaintext produces different ciphertext each time), so filtering on encrypted fields doesn't work. Only `isnull` lookups are supported. Comparing against a value raises `TypeError` rather than silently matching nothing — both `filter(api_key="x")` and the typed [condition methods](#typed-conditions-with-where) (`equals`, `contains`, …). `filter(api_key=None)` still rewrites to `IS NULL`. +- **No lookups** — encrypted values are non-deterministic (same plaintext produces different ciphertext each time), so filtering on encrypted fields doesn't work. Only `isnull` lookups are supported. Comparing against a value raises `TypeError` rather than silently matching nothing — both `filter(api_key="x")` and the typed [condition methods](#typed-conditions-with-where) (`equals`, `contains`, …), which are also rejected at the call site when the field is annotated `EncryptedField[T]`. `filter(api_key=None)` still rewrites to `IS NULL`. - **No indexes or constraints** — encrypted fields cannot be used in indexes or unique constraints. Preflight checks will catch this. - **Only `default=""`** — on `EncryptedTextField` (paired with `required=False`), the empty string is stored as plaintext `''`, so it's the one value expressible as a column `DEFAULT` (declare it to add the field to a populated table). Any other default would need ciphertext, which is non-deterministic. `EncryptedJSONField` has no persistent default at all — even `{}` serializes to text that would need ciphertext — so pair `allow_null=True` with `default=None`, which stores nothing and just marks the field optional in the constructor. diff --git a/plain-postgres/plain/postgres/__init__.py b/plain-postgres/plain/postgres/__init__.py index dee44e8324..7b34ed355c 100644 --- a/plain-postgres/plain/postgres/__init__.py +++ b/plain-postgres/plain/postgres/__init__.py @@ -36,6 +36,7 @@ URLField, UUIDField, ) +from .fields.encrypted import EncryptedField from .fields.json import JSONField from .fields.timezones import TimeZoneField from .fields.related import ( @@ -70,6 +71,10 @@ "DecimalField", "DurationField", "EmailField", + # The typed descriptor base for encrypted fields, for annotating them: + # api_key: EncryptedField[str] = types.EncryptedTextField() + # It blocks the value-comparison conditions a plain Field[T] would allow. + "EncryptedField", "F", # The typed descriptor base, for annotating model fields: # name: Field[str] = types.TextField() diff --git a/plain-postgres/plain/postgres/agents/.claude/rules/plain-postgres.md b/plain-postgres/plain/postgres/agents/.claude/rules/plain-postgres.md index 68b58103b1..f1ac1c6f66 100644 --- a/plain-postgres/plain/postgres/agents/.claude/rules/plain-postgres.md +++ b/plain-postgres/plain/postgres/agents/.claude/rules/plain-postgres.md @@ -46,9 +46,17 @@ class Article(postgres.Model): - **String forward-ref FKs** (`"self"`, `"OtherModel"`) keep a _value-type_ annotation — the checker can't resolve the string to a model: `parent: Foo | None = types.ForeignKeyField("self", on_delete=postgres.CASCADE, allow_null=True, default=None)`. + That annotation is a model instance, not a field, so `where()` traversal + (`Child.parent.name.equals(...)`) doesn't type-check through it — declare the + target above and pass the class when you want traversal typed. +- **Encrypted fields** are annotated `EncryptedField[T]` (imported from + `plain.postgres` alongside `Field`), not `Field[T]`. It's a `Field[T]` + subclass, so the constructor is typed identically, but it also carries the + `Never`-typed blocks that reject `Model.secret.equals(...)` at the call site. + A plain `Field[T]` hides them and the comparison only fails at runtime. - **JSON**: `JSONField`/`EncryptedJSONField` return `Any` from the stub (the runtime class isn't generic over its value shape), so the annotation is what - preserves typing: `Field[dict]` / `Field[dict[str, Any]]`. + preserves typing: `Field[dict]` / `EncryptedField[dict[str, Any]]`. - **Custom querysets**: declare `query: ClassVar[MyQuerySet] = MyQuerySet()` (`ClassVar` so it isn't treated as a field). Default-queryset models declare nothing — `Model.query` is typed automatically. diff --git a/plain-postgres/plain/postgres/fields/base.py b/plain-postgres/plain/postgres/fields/base.py index 68221b30ac..9878f3e409 100644 --- a/plain-postgres/plain/postgres/fields/base.py +++ b/plain-postgres/plain/postgres/fields/base.py @@ -182,6 +182,22 @@ def is_null(self, value: bool = True) -> Q: def is_in(self, values: Iterable[T]) -> Q: return self._build_q("in", values) + if TYPE_CHECKING: + # Pattern conditions are implemented on TextField, not here -- the + # runtime surface stays exactly as narrow as the field it belongs to, + # which is what where() traversal reflects. They are *declared* here + # so they survive the `Field[T]` annotation models carry: a model + # field's declared type is `Field[str]`, not `TextField[str]`, so the + # checker only sees what `Field` offers. The `self` annotation keeps + # the restriction -- a `Field[int]` still rejects `.startswith(...)`. + def contains(self: Field[str] | Field[str | None], value: str) -> Q: ... + + def icontains(self: Field[str] | Field[str | None], value: str) -> Q: ... + + def startswith(self: Field[str] | Field[str | None], value: str) -> Q: ... + + def endswith(self: Field[str] | Field[str | None], value: str) -> Q: ... + def _build_q(self, suffix: str, value: Any) -> Q: """Build a Q from a lookup suffix + value. Uses Q's positional-tuple constructor to bypass its reserved `_connector`/`_negated` kwargs that @@ -430,6 +446,25 @@ def contribute_to_class(self, cls: type[Model], name: str) -> None: setattr(cls, self.name, self) # Descriptor protocol implementation + # + # The first two overloads are class access on a *model-valued* field -- a + # foreign key. They yield `type[T]` rather than the descriptor so the + # related model's own typed field surface is reachable for where() + # traversal (`Child.parent.name.equals(...)`), matching what + # `ForwardForeignKeyDescriptor.__get__` returns at runtime (a + # `RelatedFieldRef` proxy onto the related model). They come first so they + # win over the plain `Self` overload for FK fields; a non-model T never + # matches them. + @overload + def __get__[M: Model]( + self: Field[M], instance: None, owner: type[Model] + ) -> type[M]: ... + + @overload + def __get__[M: Model]( + self: Field[M | None], instance: None, owner: type[Model] + ) -> type[M]: ... + @overload def __get__(self, instance: None, owner: type[Model]) -> Self: ... diff --git a/plain-postgres/plain/postgres/fields/encrypted.py b/plain-postgres/plain/postgres/fields/encrypted.py index 8ee8949bc7..d89cf56e2d 100644 --- a/plain-postgres/plain/postgres/fields/encrypted.py +++ b/plain-postgres/plain/postgres/fields/encrypted.py @@ -22,7 +22,7 @@ from plain import preflight -from .base import NOT_PROVIDED, validate_none_only_default +from .base import NOT_PROVIDED, Field, validate_none_only_default from .json import JSONField from .text import TextField @@ -34,6 +34,7 @@ from plain.preflight.results import PreflightResult __all__ = [ + "EncryptedField", "EncryptedJSONField", "EncryptedTextField", ] @@ -137,19 +138,26 @@ def __init__(self, lhs: Any, rhs: Any) -> None: super().__init__(lhs, rhs) -class EncryptedFieldMixin: - """Shared behavior for all encrypted fields. +class EncryptedField[T](Field[T]): + """Shared base for all encrypted fields, and the type to annotate them with. Owns the lookup surface (isnull and exact only — ciphertext is non-deterministic) and the preflight that blocks indexes and unique constraints. Also blocks the typed-query comparison methods. - Must be used with Field as a co-base class. - """ + Annotate encrypted model fields with this rather than the plain ``Field``: + + api_key: EncryptedField[str] = types.EncryptedTextField(max_length=200) - # Type hints for attributes provided by Field (the required co-base class) - name: str - model: Any + The annotation is what the type checker sees, so a plain ``Field[str]`` + would hide the ``Never``-typed blocks below and let + ``Model.api_key.equals("x")`` type-check on its way to a runtime + ``TypeError``. It is a ``Field[T]`` subclass, so the synthesized + constructor types the field exactly as ``Field[T]`` would. + + Concrete encrypted fields mix this with the field they specialize + (``TextField``, ``JSONField``), which supplies the column behavior. + """ # The complete lookup surface, replacing the base field's registry. # isnull is obviously needed. exact is required so that `filter(field=None)` @@ -162,6 +170,10 @@ class EncryptedFieldMixin: # get_lookup()/get_transform() and registry consumers (e.g. # unsupported-lookup error suggestions) all resolve through this one dict. # A classmethod so both class-level and instance-level callers work. + def __init__(self, **kwargs: Any) -> None: + # Cooperative passthrough to the concrete field this is mixed with. + super().__init__(**kwargs) + @classmethod def get_lookups(cls) -> dict[str, type[Lookup | Transform]]: return {"exact": _EncryptedExact, "isnull": IsNull} @@ -179,27 +191,44 @@ def get_transform(self, name: str) -> Callable[..., Transform] | None: # `Never` is assignable to `Q` so `where(field.equals(...))` still # type-checks at the use site, and the parameter error is the one that # surfaces. - def equals(self, value: Never) -> Never: + def equals(self, value: Never) -> Never: # ty: ignore[invalid-method-override] raise TypeError(self._lookup_unsupported_message("equals")) - def not_equal(self, value: Never) -> Never: + def not_equal(self, value: Never) -> Never: # ty: ignore[invalid-method-override] raise TypeError(self._lookup_unsupported_message("not_equal")) - def gt(self, value: Never) -> Never: + def gt(self, value: Never) -> Never: # ty: ignore[invalid-method-override] raise TypeError(self._lookup_unsupported_message("gt")) - def gte(self, value: Never) -> Never: + def gte(self, value: Never) -> Never: # ty: ignore[invalid-method-override] raise TypeError(self._lookup_unsupported_message("gte")) - def lt(self, value: Never) -> Never: + def lt(self, value: Never) -> Never: # ty: ignore[invalid-method-override] raise TypeError(self._lookup_unsupported_message("lt")) - def lte(self, value: Never) -> Never: + def lte(self, value: Never) -> Never: # ty: ignore[invalid-method-override] raise TypeError(self._lookup_unsupported_message("lte")) - def is_in(self, values: Never) -> Never: + def is_in(self, values: Never) -> Never: # ty: ignore[invalid-method-override] raise TypeError(self._lookup_unsupported_message("is_in")) + # The pattern conditions are declared on Field (implemented on TextField) + # and are as meaningless on ciphertext as the comparisons above, so they + # are blocked in the same shape: `Never` rejects the call site, the raise + # covers anyone who bypasses the type checker. EncryptedJSONField never + # had them at runtime; blocking here costs it nothing. + def contains(self, value: Never) -> Never: # ty: ignore[invalid-method-override] + raise TypeError(self._lookup_unsupported_message("contains")) + + def icontains(self, value: Never) -> Never: # ty: ignore[invalid-method-override] + raise TypeError(self._lookup_unsupported_message("icontains")) + + def startswith(self, value: Never) -> Never: # ty: ignore[invalid-method-override] + raise TypeError(self._lookup_unsupported_message("startswith")) + + def endswith(self, value: Never) -> Never: # ty: ignore[invalid-method-override] + raise TypeError(self._lookup_unsupported_message("endswith")) + def _lookup_unsupported_message(self, method: str) -> str: assert self.name is not None, ( "Encrypted field must be attached to a model before its typed-query " @@ -211,7 +240,7 @@ def _lookup_unsupported_message(self, method: str) -> str: ) def preflight(self, **kwargs: Any) -> list[PreflightResult]: - errors: list[PreflightResult] = super().preflight(**kwargs) # ty: ignore[unresolved-attribute] + errors: list[PreflightResult] = super().preflight(**kwargs) errors.extend(self._check_encrypted_constraints()) return errors @@ -257,13 +286,13 @@ def _check_encrypted_constraints(self) -> list[PreflightResult]: return errors -# The mixin narrows Field's typed-query comparison methods to `Never` on -# purpose — that narrowing is the type-level block, and it is exactly what a -# Liskov check objects to, so the override diagnostic is suppressed here. -# The suppression is class-wide (ty reports the conflict at the class line), so -# any OTHER override added to this class has to be checked by hand. +# `EncryptedField` narrows the pattern conditions `TextField` implements down to +# `Never` — that narrowing is the type-level block, and it is exactly what the +# base-class-conflict check objects to, so the diagnostic is suppressed here. +# The suppression is class-wide, so any OTHER base-class conflict introduced on +# this class has to be checked by hand. class EncryptedTextField[T: (str, str | None) = str]( # ty: ignore[invalid-method-override] - EncryptedFieldMixin, TextField[T] + EncryptedField[T], TextField[T] ): """A TextField that encrypts its value before storing in the database. @@ -300,23 +329,6 @@ def __init__( validators=validators, ) - # TextField's pattern conditions arrive with the base class and are as - # meaningless on ciphertext as the comparisons the mixin blocks, so they - # are blocked here, where they arrive. Same shape as the mixin's blocks: - # `Never` rejects the call site, the raise covers anyone who bypasses the - # type checker. - def contains(self, value: Never) -> Never: # ty: ignore[invalid-method-override] - raise TypeError(self._lookup_unsupported_message("contains")) - - def icontains(self, value: Never) -> Never: # ty: ignore[invalid-method-override] - raise TypeError(self._lookup_unsupported_message("icontains")) - - def startswith(self, value: Never) -> Never: # ty: ignore[invalid-method-override] - raise TypeError(self._lookup_unsupported_message("startswith")) - - def endswith(self, value: Never) -> Never: # ty: ignore[invalid-method-override] - raise TypeError(self._lookup_unsupported_message("endswith")) - def get_db_prep_value( self, value: Any, connection: DatabaseConnection, prepared: bool = False ) -> Any: @@ -333,12 +345,7 @@ def from_db_value( return _decrypt(value) -# The mixin narrows Field's typed-query comparison methods to `Never` on -# purpose — that narrowing is the type-level block, and it is exactly what a -# Liskov check objects to, so the override diagnostic is suppressed here. -# The suppression is class-wide (ty reports the conflict at the class line), so -# any OTHER override added to this class has to be checked by hand. -class EncryptedJSONField(EncryptedFieldMixin, JSONField): # ty: ignore[invalid-method-override] +class EncryptedJSONField(EncryptedField[Any], JSONField): """A JSONField that encrypts its serialized value before storing in the database. The JSON value is serialized to a string, encrypted, and stored as text. diff --git a/plain-postgres/plain/postgres/types.pyi b/plain-postgres/plain/postgres/types.pyi index d10a2ce335..93eb6290c1 100644 --- a/plain-postgres/plain/postgres/types.pyi +++ b/plain-postgres/plain/postgres/types.pyi @@ -514,19 +514,22 @@ def EncryptedJSONField( # Two overload families: # # 1. Class-argument FK (`to=SomeModel`) — T is inferred from the class. -# Returns `_ForeignKeyDescriptor[T, V]` whose `__get__` overloads do -# double duty: class-access (`Child.parent`) yields `type[T]` so the -# related model's typed field surface (e.g. `Child.parent.name.equals(...)`) -# is visible for typed where() chaining; instance-access (`child.parent`) -# yields V (T or T | None for nullable FKs). +# Returns `_ForeignKeyDescriptor[T, V]`, a `Field[V]`. `Field.__get__` +# does the double duty: class-access (`Child.parent`) yields `type[T]` so +# the related model's typed field surface (e.g. +# `Child.parent.name.equals(...)`) is visible for typed where() chaining; +# instance-access (`child.parent`) yields V (T or T | None for nullable +# FKs). Both survive the `Field[T]` annotation the model declares. # # 2. String-argument FK (`to="SomeModel"`, `to="self"`) — T can't be # inferred from the string, so the return type falls back to bare `T`. -# This requires an explicit LHS annotation (`parent: TreeNode | None = …`) -# but preserves instance-access typing for forward references and -# self-references. Type-level FK traversal isn't available through -# string-arg FKs — the runtime `RelatedFieldRef` still resolves -# `Child.parent.name` regardless. +# This requires an explicit *value-type* LHS annotation +# (`parent: TreeNode | None = …`) which preserves instance-access typing +# for forward references and self-references, but makes class access a +# `TreeNode` rather than a field: type-level FK traversal isn't available +# through string-arg FKs. The runtime `RelatedFieldRef` still resolves +# `Child.parent.name` regardless. Declare the model before the FK and pass +# the class when you want traversal to type-check. # # `__set__` accepts the related instance, None (via V), or a bare PK # value (int) — matching what `ForwardForeignKeyDescriptor` already @@ -539,11 +542,12 @@ def EncryptedJSONField( class _ForeignKeyDescriptor[T: Model, V](_Field[V]): # Subclasses Field[V] so an FK field is assignable to a `Field[V]` # annotation (e.g. `org: Field[Org] = types.ForeignKeyField(Org)`) under - # both ty and pyright. The FK-specific __get__/__set__ override the base. - @overload - def __get__(self, instance: None, owner: type) -> type[T]: ... - @overload - def __get__(self, instance: Model, owner: type) -> V: ... + # both ty and pyright. That annotation is also what models actually carry, + # so `__get__` is deliberately NOT overridden here -- `Field.__get__`'s + # model-valued overloads already give class access `type[T]` (traversal) + # and instance access `V`, and they keep working through the `Field[V]` + # annotation, which an override here would not. Only `__set__` is + # widened, to accept a bare PK alongside the instance. def __set__(self, instance: Model, value: V | int) -> None: ... # Class-argument FK overloads diff --git a/plain-postgres/tests/app/examples/models/encrypted.py b/plain-postgres/tests/app/examples/models/encrypted.py index 38571362df..66517470ee 100644 --- a/plain-postgres/tests/app/examples/models/encrypted.py +++ b/plain-postgres/tests/app/examples/models/encrypted.py @@ -1,6 +1,6 @@ from __future__ import annotations -from plain.postgres import Field, types +from plain.postgres import EncryptedField, Field, types from plain import postgres @@ -10,8 +10,8 @@ class SecretStore(postgres.Model): """Model for testing encrypted fields.""" name: Field[str] = types.TextField(max_length=100) - api_key: Field[str] = types.EncryptedTextField(max_length=200) - notes: Field[str] = types.EncryptedTextField(required=False, default="") - config: Field[dict | None] = types.EncryptedJSONField( + api_key: EncryptedField[str] = types.EncryptedTextField(max_length=200) + notes: EncryptedField[str] = types.EncryptedTextField(required=False, default="") + config: EncryptedField[dict | None] = types.EncryptedJSONField( required=False, allow_null=True, default=None ) diff --git a/plain-postgres/tests/public/test_typed_where.py b/plain-postgres/tests/public/test_typed_where.py index be901e8be9..96bd21c4fb 100644 --- a/plain-postgres/tests/public/test_typed_where.py +++ b/plain-postgres/tests/public/test_typed_where.py @@ -10,20 +10,25 @@ from typing import TYPE_CHECKING, assert_type from app.examples.models.defaults import DefaultsExample -from plain.postgres.fields.numeric import IntegerField -from plain.postgres.fields.text import TextField +from plain.postgres import Field from plain.postgres.query_utils import Q def test_class_access_yields_typed_descriptors() -> None: """Class-level field access returns the descriptor, parameterized by T. + The declared type is what the checker sees — models annotate their fields + `Field[T]`, so that (not the concrete `TextField[T]` the stub returns) is + the type of a class-level access. `Field[T]` therefore has to carry the + whole condition surface, including the string-only conditions, which it + restricts to string-valued fields via the `self` annotation. + These `assert_type` calls are checked by the type checker, not at runtime — but the function still has to import cleanly. """ - assert_type(DefaultsExample.name, TextField[str]) - assert_type(DefaultsExample.note, TextField[str | None]) - assert_type(DefaultsExample.priority, IntegerField[int]) + assert_type(DefaultsExample.name, Field[str]) + assert_type(DefaultsExample.note, Field[str | None]) + assert_type(DefaultsExample.priority, Field[int]) def test_instance_access_yields_value_type() -> None: @@ -67,6 +72,18 @@ def _typed_check_is_in_element_type() -> None: DefaultsExample.name.is_in(["a", "b"]) DefaultsExample.priority.is_in(["no", "ints"]) # ty: ignore[invalid-argument-type] + def _typed_check_string_conditions_are_string_only() -> None: + # The pattern conditions are declared on Field with a `self` + # annotation that restricts them to string-valued fields, so they + # survive the `Field[T]` annotation models carry without becoming + # available on every field. The ignore marker is load-bearing — if the + # restriction were dropped, ty would report it as unused. These stay + # type-check-only because a non-text field has no such method at + # runtime (AttributeError), which is what traversal reflects. + DefaultsExample.name.startswith("a") + DefaultsExample.note.contains("a") + DefaultsExample.priority.startswith("a") # ty: ignore[invalid-argument-type] + def test_field_methods_return_q_objects(): """The methods are usable before any DB hit and produce Q objects.""" From fc66c730821c29004fb00a213264e7e0f4bfaa32 Mon Sep 17 00:00:00 2001 From: Dave Gaeddert Date: Fri, 18 Sep 2026 21:33:43 -0500 Subject: [PATCH 16/21] Give every string-valued field the pattern conditions Field declares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Field` declares contains/icontains/startswith/endswith under TYPE_CHECKING, restricted by their `self` annotation to `Field[str]` / `Field[str | None]`, so they survive the `Field[T]` annotation models write. But the implementations lived on `TextField`, and two string-valued fields aren't TextFields: `GenericIPAddressField` (a DefaultableField) and `RandomStringField` (a ColumnField). So `Model.ip.contains("10.")` type-checked and raised AttributeError. Extract the four methods into `StringConditionsMixin` and mix it into all three — TextField, GenericIPAddressField, RandomStringField — so the runtime surface matches the declaration exactly. Both fields already register the underlying lookups, so the conditions execute; the end-to-end tests filter an `inet` column and a generated token. Non-string fields still get neither the declaration nor the methods, which is what keeps `test_traversed_surface_matches_direct_field_surface` and the `_CONDITION_METHODS` traversal gate honest. `EncryptedField` still precedes the mixin in EncryptedTextField's MRO, so the `Never` blocks still win. New `StringConditionsExample` fixture (migration 0020, a CreateModel so the reset test's leaf anchor stays non-empty) carries a GenericIPAddressField and a RandomStringField, and the public tests cover the Q shape, the executed query, the static types, and the absence on non-string fields. --- plain-postgres/plain/postgres/README.md | 2 +- plain-postgres/plain/postgres/fields/base.py | 52 ++++++++++-- .../plain/postgres/fields/network.py | 6 +- plain-postgres/plain/postgres/fields/text.py | 21 ++--- .../0020_stringconditionsexample.py | 21 +++++ .../tests/app/examples/models/__init__.py | 1 + .../app/examples/models/string_conditions.py | 20 +++++ .../tests/public/test_typed_where.py | 82 +++++++++++++++++++ 8 files changed, 179 insertions(+), 26 deletions(-) create mode 100644 plain-postgres/tests/app/examples/migrations/0020_stringconditionsexample.py create mode 100644 plain-postgres/tests/app/examples/models/string_conditions.py diff --git a/plain-postgres/plain/postgres/README.md b/plain-postgres/plain/postgres/README.md index b27d719064..806026296e 100644 --- a/plain-postgres/plain/postgres/README.md +++ b/plain-postgres/plain/postgres/README.md @@ -238,7 +238,7 @@ Conditions traverse foreign keys — accessing a field through a relation builds Post.query.where(Post.author.email.equals("a@example.com")) ``` -A traversed field offers exactly the same conditions as the field itself — a text-only method like `contains` is available through the relation only when the related field is a text field. +A traversed field offers exactly the same conditions as the field itself — a string-only method like `contains` is available through the relation only when the related field is string-valued (any of them, not just `TextField`: `GenericIPAddressField` and `RandomStringField` carry them too). [Encrypted fields](#encrypted-fields) reject value comparisons because their ciphertext is non-deterministic — only `is_null()` is available, and any other condition method (`equals`, `is_in`, …) raises `TypeError`. diff --git a/plain-postgres/plain/postgres/fields/base.py b/plain-postgres/plain/postgres/fields/base.py index 9878f3e409..da58493ebe 100644 --- a/plain-postgres/plain/postgres/fields/base.py +++ b/plain-postgres/plain/postgres/fields/base.py @@ -183,13 +183,16 @@ def is_in(self, values: Iterable[T]) -> Q: return self._build_q("in", values) if TYPE_CHECKING: - # Pattern conditions are implemented on TextField, not here -- the - # runtime surface stays exactly as narrow as the field it belongs to, - # which is what where() traversal reflects. They are *declared* here - # so they survive the `Field[T]` annotation models carry: a model - # field's declared type is `Field[str]`, not `TextField[str]`, so the - # checker only sees what `Field` offers. The `self` annotation keeps - # the restriction -- a `Field[int]` still rejects `.startswith(...)`. + # Pattern conditions are implemented by StringConditionsMixin, not + # here -- the runtime surface stays exactly as narrow as the field it + # belongs to, which is what where() traversal reflects. They are + # *declared* here so they survive the `Field[T]` annotation models + # carry: a model field's declared type is `Field[str]`, not + # `TextField[str]`, so the checker only sees what `Field` offers. The + # `self` annotation keeps the restriction -- a `Field[int]` still + # rejects `.startswith(...)`. Every field that satisfies this `self` + # type has to mix in StringConditionsMixin, or the declaration + # promises a method the instance doesn't have. def contains(self: Field[str] | Field[str | None], value: str) -> Q: ... def icontains(self: Field[str] | Field[str | None], value: str) -> Q: ... @@ -641,6 +644,41 @@ def validate_none_only_default( raise TypeError(f"{name}(default=None) requires allow_null=True.") +class StringConditionsMixin: + """The pattern conditions that every string-valued field carries. + + `Field` *declares* these under TYPE_CHECKING, restricted by their `self` + annotation to `Field[str]` / `Field[str | None]`, so they survive the + `Field[T]` annotation models write. This is where they are implemented, + and the two have to agree: a field whose value type is `str` must mix this + in, or the declaration promises a method that raises `AttributeError`. + + That means TextField and its subclasses, and also the string-valued fields + that are *not* TextFields -- `RandomStringField` (a ColumnField) and + `GenericIPAddressField` (a DefaultableField). Non-string fields get + neither the declaration nor the methods, which is what keeps where() + traversal's surface an honest mirror of direct field access. + + Must be used with Field as a co-base class. + """ + + if TYPE_CHECKING: + # Provided by Field, the required co-base class. + def _build_q(self, suffix: str, value: Any) -> Q: ... + + def contains(self, value: str) -> Q: + return self._build_q("contains", value) + + def icontains(self, value: str) -> Q: + return self._build_q("icontains", value) + + def startswith(self, value: str) -> Q: + return self._build_q("startswith", value) + + def endswith(self, value: str) -> Q: + return self._build_q("endswith", value) + + class ColumnField[T](Field[T]): """Base for fields backed by a column value (required/allow_null/validators).""" diff --git a/plain-postgres/plain/postgres/fields/network.py b/plain-postgres/plain/postgres/fields/network.py index a03c5c215b..354e1c9618 100644 --- a/plain-postgres/plain/postgres/fields/network.py +++ b/plain-postgres/plain/postgres/fields/network.py @@ -10,13 +10,15 @@ from plain import exceptions -from .base import NOT_PROVIDED, DefaultableField +from .base import NOT_PROVIDED, DefaultableField, StringConditionsMixin if TYPE_CHECKING: from plain.postgres.connection import DatabaseConnection -class GenericIPAddressField[T: (str, str | None) = str](DefaultableField[T]): +class GenericIPAddressField[T: (str, str | None) = str]( + StringConditionsMixin, DefaultableField[T] +): db_type_sql = "inet" empty_strings_allowed = False diff --git a/plain-postgres/plain/postgres/fields/text.py b/plain-postgres/plain/postgres/fields/text.py index 1df069ac35..ed35f75132 100644 --- a/plain-postgres/plain/postgres/fields/text.py +++ b/plain-postgres/plain/postgres/fields/text.py @@ -8,14 +8,13 @@ from plain import validators -from .base import NOT_PROVIDED, ChoicesField, ColumnField +from .base import NOT_PROVIDED, ChoicesField, ColumnField, StringConditionsMixin if TYPE_CHECKING: from plain.postgres.functions.random import RandomString - from plain.postgres.query_utils import Q -class TextField[T: (str, str | None) = str](ChoicesField[T]): +class TextField[T: (str, str | None) = str](StringConditionsMixin, ChoicesField[T]): db_type_sql = "text" def __init__( @@ -88,18 +87,6 @@ def get_prep_value(self, value: Any) -> Any: value = super().get_prep_value(value) return self.to_python(value) - def contains(self, value: str) -> Q: - return self._build_q("contains", value) - - def icontains(self, value: str) -> Q: - return self._build_q("icontains", value) - - def startswith(self, value: str) -> Q: - return self._build_q("startswith", value) - - def endswith(self, value: str) -> Q: - return self._build_q("endswith", value) - class EmailField[T: (str, str | None) = str](TextField[T]): default_validators = (validators.validate_email,) @@ -109,7 +96,9 @@ class URLField[T: (str, str | None) = str](TextField[T]): default_validators = (validators.URLValidator(),) -class RandomStringField[T: (str, str | None) = str](ColumnField[T]): +class RandomStringField[T: (str, str | None) = str]( + StringConditionsMixin, ColumnField[T] +): """Text column whose value is a Postgres-generated random hex string. The column carries a ``DEFAULT`` that evaluates per row, so raw SQL and diff --git a/plain-postgres/tests/app/examples/migrations/0020_stringconditionsexample.py b/plain-postgres/tests/app/examples/migrations/0020_stringconditionsexample.py new file mode 100644 index 0000000000..ecfba25b03 --- /dev/null +++ b/plain-postgres/tests/app/examples/migrations/0020_stringconditionsexample.py @@ -0,0 +1,21 @@ +# Generated by Plain 0.163.1 on 2026-09-19 02:31 + +from plain.postgres import migrations + +from plain import postgres + + +class Migration(migrations.Migration): + dependencies = (("examples", "0019_shadowtarget_shadowsource"),) + + operations = ( + migrations.CreateModel( + name="StringConditionsExample", + fields=[ + ("id", postgres.PrimaryKeyField()), + ("ip", postgres.GenericIPAddressField()), + ("label", postgres.TextField(max_length=50)), + ("token", postgres.RandomStringField(length=16)), + ], + ), + ) diff --git a/plain-postgres/tests/app/examples/models/__init__.py b/plain-postgres/tests/app/examples/models/__init__.py index be3f08bf59..6fc95fa0d4 100644 --- a/plain-postgres/tests/app/examples/models/__init__.py +++ b/plain-postgres/tests/app/examples/models/__init__.py @@ -15,6 +15,7 @@ relationships, shadowing, storage_parameters, + string_conditions, trees, unregistered, ) diff --git a/plain-postgres/tests/app/examples/models/string_conditions.py b/plain-postgres/tests/app/examples/models/string_conditions.py new file mode 100644 index 0000000000..ec92ea763f --- /dev/null +++ b/plain-postgres/tests/app/examples/models/string_conditions.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from plain.postgres import Field, types + +from plain import postgres + + +@postgres.register_model +class StringConditionsExample(postgres.Model): + """String-valued fields that are *not* TextField subclasses. + + `Field` declares the pattern conditions for every string-valued field, so + these have to carry them at runtime too — `GenericIPAddressField` is a + DefaultableField and `RandomStringField` is a ColumnField, and neither + inherits TextField's implementations. + """ + + label: Field[str] = types.TextField(max_length=50) + ip: Field[str] = types.GenericIPAddressField() + token: Field[str] = types.RandomStringField(length=16) diff --git a/plain-postgres/tests/public/test_typed_where.py b/plain-postgres/tests/public/test_typed_where.py index 96bd21c4fb..8d6eb01073 100644 --- a/plain-postgres/tests/public/test_typed_where.py +++ b/plain-postgres/tests/public/test_typed_where.py @@ -9,10 +9,14 @@ from typing import TYPE_CHECKING, assert_type +import pytest from app.examples.models.defaults import DefaultsExample +from app.examples.models.string_conditions import StringConditionsExample from plain.postgres import Field from plain.postgres.query_utils import Q +PATTERN_CONDITIONS = ["contains", "icontains", "startswith", "endswith"] + def test_class_access_yields_typed_descriptors() -> None: """Class-level field access returns the descriptor, parameterized by T. @@ -185,3 +189,81 @@ def test_is_null_with_explicit_default(db): non_nulls = list(DefaultsExample.query.where(DefaultsExample.note.is_null(False))) assert [r.name for r in non_nulls] == ["bob"] + + +# --------------------------------------------------------------------------- +# Pattern conditions on string-valued fields that aren't TextFields. +# +# `Field` declares contains/icontains/startswith/endswith for every +# string-valued field (that's what carries them through the `Field[str]` +# annotation models write), so every string-valued field has to implement them. +# `GenericIPAddressField` is a DefaultableField and `RandomStringField` is a +# ColumnField — neither inherits TextField's implementations, so both mix in +# the shared implementation instead. Without it the declaration promises a +# method that raises AttributeError. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("method", PATTERN_CONDITIONS) +def test_ip_address_field_pattern_conditions_build_q(method: str) -> None: + q = getattr(StringConditionsExample.ip, method)("10.") + assert q.children == [(f"ip__{method}", "10.")] + + +@pytest.mark.parametrize("method", PATTERN_CONDITIONS) +def test_random_string_field_pattern_conditions_build_q(method: str) -> None: + q = getattr(StringConditionsExample.token, method)("ab") + assert q.children == [(f"token__{method}", "ab")] + + +def test_ip_address_field_pattern_conditions_type_check() -> None: + """The same calls spelled statically, so ty checks them rather than going + through getattr. `assert_type` pins the return so a silently-Any surface + would fail here.""" + assert_type(StringConditionsExample.ip.contains("0.0"), Q) + assert_type(StringConditionsExample.ip.icontains("0.0"), Q) + assert_type(StringConditionsExample.ip.startswith("10."), Q) + assert_type(StringConditionsExample.ip.endswith(".1"), Q) + assert_type(StringConditionsExample.token.contains("ab"), Q) + assert_type(StringConditionsExample.token.icontains("ab"), Q) + assert_type(StringConditionsExample.token.startswith("ab"), Q) + assert_type(StringConditionsExample.token.endswith("yz"), Q) + + +def test_where_filters_ip_address_field_by_pattern(db): + StringConditionsExample.query.create(label="private", ip="10.0.0.1") + StringConditionsExample.query.create(label="local", ip="192.168.1.1") + + rows = list( + StringConditionsExample.query.where( + StringConditionsExample.ip.startswith("10.") + ) + ) + assert [r.label for r in rows] == ["private"] + + +def test_where_filters_random_string_field_by_pattern(db): + """The token is generated by Postgres, so read one back and match on it.""" + StringConditionsExample.query.create(label="only", ip="10.0.0.1") + + token = StringConditionsExample.query.get(label="only").token + assert len(token) == 16 + + rows = list( + StringConditionsExample.query.where( + StringConditionsExample.token.startswith(token[:4]) + ) + ) + assert [r.label for r in rows] == ["only"] + + +def test_non_string_fields_have_no_pattern_conditions() -> None: + """The runtime surface has to stay as narrow as the declaration: a field + whose value type isn't a string gets neither. This is also what keeps + where() traversal an honest mirror of direct field access.""" + for method in PATTERN_CONDITIONS: + assert hasattr(StringConditionsExample.label, method) + assert hasattr(StringConditionsExample.ip, method) + assert hasattr(StringConditionsExample.token, method) + assert not hasattr(StringConditionsExample.id, method) + assert not hasattr(DefaultsExample.priority, method) From f37b02c3ce98ffc39cccccb23dbe7d352acf2367 Mon Sep 17 00:00:00 2001 From: Dave Gaeddert Date: Fri, 18 Sep 2026 21:58:49 -0500 Subject: [PATCH 17/21] Address Codex review on #84: FK conditions, README example, descriptor pickling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three P2 findings, each reproduced first. **Conditions on the relation itself** (related_typed.py). `Child.parent.equals(obj)` and `Child.parent.is_null()` raised `AttributeError: parent.equals is not a traversable field or relation` — RelatedFieldRef treated the condition name as a field on the related model. The methods deliberately aren't added: to the type checker `Child.parent` is `type[Parent]` (Field.__get__'s model-valued overloads), which is what makes `Child.parent.name.equals(...)` type-check, and a runtime method the checker rejects is worse than none. Instead the proxy now raises a TypeError naming the spelling that works — `Child.parent.id.equals(...)`, which compiles to the same `parent__id=` lookup `filter(parent=obj)` produces. The field lookup still runs first, so a related model with a real column named `equals` keeps traversing to it. RelatedFieldRef carries the FK's target field name so the message names the actual key. **README typed-query example** annotated `email: str` / `age: int` on `types.*` assignments and redeclared `query`, all pre-#83 — 3 ty errors as written (`str has no attribute equals`, `int has no attribute gte`, invalid `query` override). Now `Field[str]` / `Field[int | None]` with the `Field` import, and every snippet in the section verified through a scratch probe. **Descriptor pickling** (related_descriptors.py). `__reduce__` reconstructed with `getattr`, which runs `__get__` and returns a RelatedFieldRef — a pickled ForwardForeignKeyDescriptor came back as a traversal proxy with no `.field`. Reconstruct with `inspect.getattr_static`, the same way the prefetch path already reaches for the descriptor. Tests: relation-key conditions (Q shape, end-to-end equals / is_in / is_null, and an assertion that the typed spelling returns exactly what `filter(parent=)` returns), the helpful error across every condition name, the surviving AttributeError for a genuine typo, condition-named related columns still traversing, and pickle round-trips for a required and a nullable relation. Documented the spelling in the README and the postgres rule. --- .claude/rules/plain-postgres.md | 2 + plain-postgres/plain/postgres/README.md | 21 ++-- .../agents/.claude/rules/plain-postgres.md | 2 + .../postgres/fields/related_descriptors.py | 15 ++- .../plain/postgres/fields/related_typed.py | 30 +++++- plain-postgres/tests/public/test_related.py | 26 +++++ .../tests/public/test_typed_where_fk.py | 102 ++++++++++++++++++ 7 files changed, 186 insertions(+), 12 deletions(-) diff --git a/.claude/rules/plain-postgres.md b/.claude/rules/plain-postgres.md index f1ac1c6f66..a46a11dfc0 100644 --- a/.claude/rules/plain-postgres.md +++ b/.claude/rules/plain-postgres.md @@ -98,6 +98,8 @@ Run `uv run plain docs postgres` for full workflow details. Use `Model.query` to build querysets (e.g., `User.query.filter(is_active=True)`). +- `where()` takes typed conditions built off fields (`User.query.where(User.role.equals("admin"))`) instead of `filter()`'s string kwargs, so a typo or wrong value type is caught at the call site. +- **Conditions on a relation go through its key**: `Post.query.where(Post.author.id.equals(author.id))`, `.id.is_in([...])`, `.id.is_null()` — the typed spelling of `filter(author=author)`, same SQL. `Post.author.equals(author)` raises `TypeError`: `Post.author` is `type[Author]` to the checker (which is what makes `Post.author.email.equals(...)` work), so it offers the related model's fields, not conditions. - Use `select_related()` for FK access in loops, `prefetch_related()` for reverse/M2N - A foreign key returns a partial related object: `obj.author` and `obj.author.id` are query-free; other fields load on first access. There is no `obj.author_id` — use `obj.author.id` - Use `.annotate(Count(...))` instead of calling `.count()` per row diff --git a/plain-postgres/plain/postgres/README.md b/plain-postgres/plain/postgres/README.md index 806026296e..6c7c6494d9 100644 --- a/plain-postgres/plain/postgres/README.md +++ b/plain-postgres/plain/postgres/README.md @@ -203,16 +203,15 @@ For more advanced querying options, see the [`QuerySet`](./query.py#QuerySet) cl `where()` is a typed alternative to `filter()`. Instead of string keyword lookups, you build each condition from a field, so a type checker catches a misspelled field or a wrong value type at the call site: ```python -from plain.postgres import types +from plain import postgres +from plain.postgres import Field, types @postgres.register_model class User(postgres.Model): - email: str = types.EmailField() - role: str = types.TextField(max_length=20) - age: int = types.IntegerField(allow_null=True) - - query: postgres.QuerySet[User] = postgres.QuerySet() + email: Field[str] = types.EmailField() + role: Field[str] = types.TextField(max_length=20) + age: Field[int | None] = types.IntegerField(allow_null=True, default=None) # Each argument is a condition; multiple arguments are ANDed together. @@ -238,6 +237,16 @@ Conditions traverse foreign keys — accessing a field through a relation builds Post.query.where(Post.author.email.equals("a@example.com")) ``` +A relation is a path to traverse, not a field, so it carries no conditions of its own. To match on the relation itself, traverse to the key it points at — that's the typed spelling of `filter(author=author)`, and it compiles to the same SQL: + +```python +Post.query.where(Post.author.id.equals(author.id)) +Post.query.where(Post.author.id.is_in([a.id for a in authors])) +Post.query.where(Post.author.id.is_null()) # nullable relation +``` + +`Post.author.equals(author)` raises `TypeError` naming this spelling. It isn't an oversight: to the type checker `Post.author` is `type[Author]`, which is what makes `Post.author.email.equals(...)` type-check, and a condition method there would be a runtime method the checker rejects. + A traversed field offers exactly the same conditions as the field itself — a string-only method like `contains` is available through the relation only when the related field is string-valued (any of them, not just `TextField`: `GenericIPAddressField` and `RandomStringField` carry them too). [Encrypted fields](#encrypted-fields) reject value comparisons because their ciphertext is non-deterministic — only `is_null()` is available, and any other condition method (`equals`, `is_in`, …) raises `TypeError`. diff --git a/plain-postgres/plain/postgres/agents/.claude/rules/plain-postgres.md b/plain-postgres/plain/postgres/agents/.claude/rules/plain-postgres.md index f1ac1c6f66..a46a11dfc0 100644 --- a/plain-postgres/plain/postgres/agents/.claude/rules/plain-postgres.md +++ b/plain-postgres/plain/postgres/agents/.claude/rules/plain-postgres.md @@ -98,6 +98,8 @@ Run `uv run plain docs postgres` for full workflow details. Use `Model.query` to build querysets (e.g., `User.query.filter(is_active=True)`). +- `where()` takes typed conditions built off fields (`User.query.where(User.role.equals("admin"))`) instead of `filter()`'s string kwargs, so a typo or wrong value type is caught at the call site. +- **Conditions on a relation go through its key**: `Post.query.where(Post.author.id.equals(author.id))`, `.id.is_in([...])`, `.id.is_null()` — the typed spelling of `filter(author=author)`, same SQL. `Post.author.equals(author)` raises `TypeError`: `Post.author` is `type[Author]` to the checker (which is what makes `Post.author.email.equals(...)` work), so it offers the related model's fields, not conditions. - Use `select_related()` for FK access in loops, `prefetch_related()` for reverse/M2N - A foreign key returns a partial related object: `obj.author` and `obj.author.id` are query-free; other fields load on first access. There is no `obj.author_id` — use `obj.author.id` - Use `.annotate(Count(...))` instead of calling `.count()` per row diff --git a/plain-postgres/plain/postgres/fields/related_descriptors.py b/plain-postgres/plain/postgres/fields/related_descriptors.py index eeda7b1487..04f825edc3 100644 --- a/plain-postgres/plain/postgres/fields/related_descriptors.py +++ b/plain-postgres/plain/postgres/fields/related_descriptors.py @@ -36,6 +36,7 @@ class Child(Model): from __future__ import annotations +import inspect from functools import cached_property from typing import Any @@ -133,7 +134,9 @@ def __get__(self, instance: Any | None, cls: type | None = None) -> Any: from plain.postgres.fields.related_typed import RelatedFieldRef return RelatedFieldRef( - model=self.field.remote_field.model, prefix=self.field.name + model=self.field.remote_field.model, + prefix=self.field.name, + target_name=self.field.target_field.name, ) # The related object is cached on the model state -- by select_related, @@ -228,10 +231,14 @@ def __delete__(self, instance: Any) -> None: def __reduce__(self) -> tuple[Any, tuple[Any, str]]: """ Pickling should return the instance attached by self.field on the - model, not a new copy of that descriptor. Use getattr() to retrieve - the instance directly from the model. + model, not a new copy of that descriptor. + + Reconstruct with ``inspect.getattr_static``, not ``getattr``: class + access runs ``__get__``, which returns a ``RelatedFieldRef`` traversal + proxy, so a plain ``getattr`` would unpickle the descriptor as a proxy. + Same reason the prefetch path reaches for the descriptor statically. """ - return getattr, (self.field.model, self.field.name) + return inspect.getattr_static, (self.field.model, self.field.name) class ForwardManyToManyDescriptor: diff --git a/plain-postgres/plain/postgres/fields/related_typed.py b/plain-postgres/plain/postgres/fields/related_typed.py index cb518e40e8..638089089b 100644 --- a/plain-postgres/plain/postgres/fields/related_typed.py +++ b/plain-postgres/plain/postgres/fields/related_typed.py @@ -72,15 +72,28 @@ class RelatedFieldRef: not attribute lookup, so a related field whose name collides with a public attribute on the FK descriptor (`field`, `is_cached`, `get_queryset`, …) still resolves to that field. + + A relation is not itself a field, so it carries no condition methods. + `Child.parent.equals(obj)` is spelled `Child.parent.id.equals(obj.id)` -- + traversal to the key the relation targets, which compiles to the same + `parent__id=` lookup `filter(parent=obj)` produces. This can't be smoothed + over by adding the methods here: to the type checker `Child.parent` is + `type[Parent]` (see `Field.__get__`'s model-valued overloads), which is + what makes chained traversal type-check, and a runtime method the checker + rejects would be worse than no method at all. `__getattr__` raises a + TypeError pointing at the right spelling instead. """ - def __init__(self, model: type[Model], prefix: str) -> None: + def __init__(self, model: type[Model], prefix: str, target_name: str) -> None: assert not isinstance(model, str), ( "RelatedFieldRef requires a resolved model class; the FK's " "remote_field.model is replaced with the class at registration." ) self._model = model self._prefix = prefix + # The field on the related model that this relation targets -- the hop + # a condition on the relation has to go through. + self._target_name = target_name def __repr__(self) -> str: return f"" @@ -94,13 +107,26 @@ def __getattr__(self, name: str) -> Any: try: field = self._model._model_meta.get_forward_field(name) except FieldDoesNotExist: + # The field lookup comes first so a related model that really does + # have a column named `equals` (or `contains`, …) still traverses + # to it -- same rule as the descriptor-attribute shadowing above. + if name in _CONDITION_METHODS: + raise TypeError( + f"{self._prefix}.{name}() is not available: " + f"{self._prefix!r} is a relation, not a field. Build the " + f"condition on the key it points at instead -- " + f"{self._prefix}.{self._target_name}.{name}(...), which " + f"compiles to the same SQL." + ) from None raise AttributeError( f"{self._prefix}.{name} is not a traversable field or relation" ) from None if isinstance(field, ForeignKeyField): return RelatedFieldRef( - model=field.remote_field.model, prefix=f"{self._prefix}__{name}" + model=field.remote_field.model, + prefix=f"{self._prefix}__{name}", + target_name=field.target_field.name, ) return PrefixedFieldRef(field=field, parent_path=self._prefix) diff --git a/plain-postgres/tests/public/test_related.py b/plain-postgres/tests/public/test_related.py index 35c482ef7b..c28868c91c 100644 --- a/plain-postgres/tests/public/test_related.py +++ b/plain-postgres/tests/public/test_related.py @@ -1,3 +1,6 @@ +import inspect +import pickle + import pytest from app.examples.models.delete import ( ChildCascade, @@ -666,3 +669,26 @@ def test_del_missing_foreign_key_keeps_cache_intact(self, db): with pytest.raises(AttributeError): del child.parent assert fk_field.is_cached(child) # ty: ignore[unresolved-attribute] + + +class TestForwardForeignKeyDescriptorPickling: + """The descriptor pickles by name off its model. Class access runs + `__get__`, which returns a RelatedFieldRef traversal proxy, so the + reconstruction has to be static or the descriptor comes back as a proxy.""" + + def test_pickle_round_trips_to_the_descriptor(self): + descriptor = inspect.getattr_static(ChildCascade, "parent") + + restored = pickle.loads(pickle.dumps(descriptor)) + + assert type(restored) is type(descriptor) + assert restored is descriptor # the one attached to the model + assert restored.field is ChildCascade._model_meta.get_forward_field("parent") + + def test_pickle_round_trips_a_nullable_relation(self): + descriptor = inspect.getattr_static(ChildSetNull, "parent") + + restored = pickle.loads(pickle.dumps(descriptor)) + + assert restored is descriptor + assert restored.field.name == "parent" diff --git a/plain-postgres/tests/public/test_typed_where_fk.py b/plain-postgres/tests/public/test_typed_where_fk.py index 2c11ff76a7..db1a52299b 100644 --- a/plain-postgres/tests/public/test_typed_where_fk.py +++ b/plain-postgres/tests/public/test_typed_where_fk.py @@ -10,6 +10,7 @@ import pytest from app.examples.models.delete import ( ChildCascade, + ChildSetNull, DeleteParent, Grandchild, Grandparent, @@ -275,3 +276,104 @@ def test_shadowed_field_traversal_runs(db): rows = list(ShadowSource.query.where(ShadowSource.ref.field.equals("hit"))) assert [r.ref.id for r in rows] == [matched.id] + + +# --------------------------------------------------------------------------- +# Conditions on the relation itself. +# +# A relation is not a field, so it has no condition methods -- and it can't be +# given any. To the type checker `ChildCascade.parent` is `type[DeleteParent]` +# (Field.__get__'s model-valued overloads), which is exactly what lets chained +# traversal type-check; a runtime `.equals()` the checker rejects would be +# worse than none. The spelling is traversal to the key the relation targets, +# which compiles to the same lookup `filter(parent=obj)` produces. +# --------------------------------------------------------------------------- + + +def test_relation_key_conditions_build_q(): + assert ChildCascade.parent.id.equals(7).children == [("parent__id", 7)] + assert ChildCascade.parent.id.is_in([1, 2]).children == [("parent__id__in", [1, 2])] + assert ChildSetNull.parent.id.is_null().children == [("parent__id__isnull", True)] + assert ChildCascade.parent.id.not_equal(7).children == [("parent__id", 7)] + + +def test_where_filters_by_relation_key(db): + kept = DeleteParent.query.create(name="kept") + other = DeleteParent.query.create(name="other") + mine = ChildCascade.query.create(parent=kept) + ChildCascade.query.create(parent=other) + + rows = list(ChildCascade.query.where(ChildCascade.parent.id.equals(kept.id))) + assert [r.id for r in rows] == [mine.id] + + +def test_relation_key_matches_filter_on_the_relation(db): + """The documented equivalence: traversing to the key is the typed spelling + of `filter(parent=obj)`, not merely something similar.""" + kept = DeleteParent.query.create(name="kept") + other = DeleteParent.query.create(name="other") + ChildCascade.query.create(parent=kept) + ChildCascade.query.create(parent=other) + + typed = [ + r.id for r in ChildCascade.query.where(ChildCascade.parent.id.equals(kept.id)) + ] + untyped = [r.id for r in ChildCascade.query.filter(parent=kept)] + assert typed == untyped + assert len(typed) == 1 + + +def test_where_filters_by_relation_key_is_in(db): + a = DeleteParent.query.create(name="a") + b = DeleteParent.query.create(name="b") + c = DeleteParent.query.create(name="c") + for parent in (a, b, c): + ChildCascade.query.create(parent=parent) + + rows = list(ChildCascade.query.where(ChildCascade.parent.id.is_in([a.id, c.id]))) + assert sorted(r.parent.id for r in rows) == sorted([a.id, c.id]) + + +def test_where_filters_by_null_relation_key(db): + kept = DeleteParent.query.create(name="kept") + doomed = DeleteParent.query.create(name="doomed") + attached = ChildSetNull.query.create(parent=kept) + orphan = ChildSetNull.query.create(parent=doomed) + + # The field is nullable but still `required`, so the null arrives the way + # it does in practice: SET_NULL clearing the key when the parent goes. + DeleteParent.query.filter(id=doomed.id).delete() + + nulls = list(ChildSetNull.query.where(ChildSetNull.parent.id.is_null())) + assert [r.id for r in nulls] == [orphan.id] + + non_nulls = list(ChildSetNull.query.where(ChildSetNull.parent.id.is_null(False))) + assert [r.id for r in non_nulls] == [attached.id] + + +@pytest.mark.parametrize( + "method", + ["equals", "not_equal", "gt", "gte", "lt", "lte", "is_null", "is_in", "contains"], +) +def test_condition_on_the_relation_itself_raises_helpful_error(method): + """Not the generic AttributeError -- the error has to name the spelling + that works, or the constraint just looks like a missing feature.""" + with pytest.raises(TypeError) as excinfo: + getattr(ChildCascade.parent, method) + + message = str(excinfo.value) + assert "is a relation, not a field" in message + assert f"parent.id.{method}(...)" in message + + +def test_unknown_relation_attribute_still_raises_attribute_error(): + """Only the condition names get the TypeError; a genuine typo stays an + AttributeError so `hasattr` and friends behave.""" + with pytest.raises(AttributeError, match="parent.nope is not a traversable"): + getattr(ChildCascade.parent, "nope") + + +def test_related_field_named_like_a_condition_still_traverses(): + """The field lookup runs first, so a related model that really does have a + column named after a condition method still resolves to the column.""" + assert ShadowSource.ref.field.equals("hit").children == [("ref__field", "hit")] From cb5bc3c9d61685f8c1f08686e88a995c6124ef90 Mon Sep 17 00:00:00 2001 From: Dave Gaeddert Date: Fri, 18 Sep 2026 23:23:47 -0500 Subject: [PATCH 18/21] Put the whole condition surface on Field Four reviewers converged on the same shape: conditions belong on `Field`, and everything else should route through them rather than re-implement them. **Traversal hands back the field itself.** `PrefixedFieldRef` wrapped a field, delegated each condition by name through a hand-typed `_CONDITION_METHODS` gate, then rewrote the resulting Q's keys with `_prefix_q` -- three copies of one idea, and `_prefix_q` mutated `Q.children` in place, which forbids ever caching a Q. All of it is replaced by `Field.with_lookup_prefix(prefix)`: a detached copy (via the existing `Field.__copy__`) whose `name` carries the relation path, so the field's own condition methods build the right keys. The traversed surface is now identical to direct access by construction instead of by a gate that had to be kept in sync -- including an encrypted field's refusals, whose message names the full `store__api_key` path for free. **String conditions move onto Field**, keeping the `self: Field[str] | Field[str | None]` restriction the TYPE_CHECKING declarations already carried, so every string-valued field has them regardless of base class and `StringConditionsMixin` disappears. Note the runtime guard that was proposed alongside this can't exist: `Contains`/`IContains`/`StartsWith`/`EndsWith` are registered on `Field` itself, so nothing at runtime distinguishes a string field from an int one. The `self` restriction is the guard, and the test says so. `_build_q` does now reject a lookup a field hasn't registered at all. **Encrypted fields get one runtime guard instead of eleven.** The eleven `Never`-typed signatures stay as bodiless declarations under `if TYPE_CHECKING:` -- they are the static block -- and `EncryptedField._build_q` implements it once, refusing anything but `isnull`. That closes the loop for conditions that don't exist yet. `_build_q` takes the method name so messages still name `.gt()`. `_EncryptedExact` reads `lhs.output_field.name` instead of a `getattr` chain with a `""` sentinel; it keeps its own sentence because `filter(field=None)` *is* supported and "does not support .filter()" would be wrong. **Ordering conditions reject a None operand** at the call site. On a nullable field `T` includes None, so `age.gte(None)` got past the checker and died much later as `ValueError("Cannot use None as a query value")`. This can't be fixed statically -- probed against ty 0.0.80 and pyright 1.1.414, `self: Field[X | None], value: X` selects the intended overload but solves `X` as `int | None`, and None can't be subtracted from a TypeVar -- so it raises here instead, with the field and method in the message. `equals(None)` and `is_null()` stay legal. Also: the condition-method names live once as `CONDITION_METHODS` / `STRING_CONDITION_METHODS` next to the methods (there were four copies); the two model-valued `__get__` overloads collapse into one `self: Field[M] | Field[M | None]` (verified on both checkers); `LOOKUP_SEP` replaces literal `"__"`; the `assert self.name is not None` guards that could never fire become truthiness asserts; and `RelatedFieldRef`'s "relation, not a field" error is an AttributeError so `hasattr` and `getattr(..., default)` keep working. --- plain-postgres/plain/postgres/README.md | 2 +- plain-postgres/plain/postgres/fields/base.py | 180 ++++++++++-------- .../plain/postgres/fields/encrypted.py | 108 +++++------ .../plain/postgres/fields/network.py | 6 +- .../plain/postgres/fields/related_typed.py | 114 +++-------- plain-postgres/plain/postgres/fields/text.py | 8 +- .../tests/public/test_typed_where.py | 94 ++++----- .../tests/public/test_typed_where_fk.py | 132 +++++-------- 8 files changed, 278 insertions(+), 366 deletions(-) diff --git a/plain-postgres/plain/postgres/README.md b/plain-postgres/plain/postgres/README.md index 6c7c6494d9..a3217c3666 100644 --- a/plain-postgres/plain/postgres/README.md +++ b/plain-postgres/plain/postgres/README.md @@ -247,7 +247,7 @@ Post.query.where(Post.author.id.is_null()) # nullable relation `Post.author.equals(author)` raises `TypeError` naming this spelling. It isn't an oversight: to the type checker `Post.author` is `type[Author]`, which is what makes `Post.author.email.equals(...)` type-check, and a condition method there would be a runtime method the checker rejects. -A traversed field offers exactly the same conditions as the field itself — a string-only method like `contains` is available through the relation only when the related field is string-valued (any of them, not just `TextField`: `GenericIPAddressField` and `RandomStringField` carry them too). +A traversed field _is_ the related field, carrying the relation path as its name — so it offers exactly the conditions that field offers, including an encrypted field's refusals. [Encrypted fields](#encrypted-fields) reject value comparisons because their ciphertext is non-deterministic — only `is_null()` is available, and any other condition method (`equals`, `is_in`, …) raises `TypeError`. diff --git a/plain-postgres/plain/postgres/fields/base.py b/plain-postgres/plain/postgres/fields/base.py index da58493ebe..8fc9683906 100644 --- a/plain-postgres/plain/postgres/fields/base.py +++ b/plain-postgres/plain/postgres/fields/base.py @@ -97,6 +97,10 @@ def _empty(of_cls: type) -> Empty: return new +# Ordering conditions: the ones a None operand is meaningless for. +_ORDERING_SUFFIXES = frozenset({"gt", "gte", "lt", "lte"}) + + class Field[T](RegisterLookupMixin): """Base class for all field types""" @@ -158,60 +162,105 @@ def __repr__(self) -> str: # Typed query conditions. Available on every field; subclasses extend # with type-specific lookups (comparison on numeric, string ops on text). + # The names are listed once in CONDITION_METHODS below -- anything that + # needs the set (traversal advice, tests) imports it rather than retyping. def equals(self, value: T) -> Q: - return self._build_q("", value) + return self._build_q("equals", "", value) def not_equal(self, value: T) -> Q: - return ~self._build_q("", value) + return ~self._build_q("not_equal", "", value) def gt(self, value: T) -> Q: - return self._build_q("gt", value) + return self._build_q("gt", "gt", value) def gte(self, value: T) -> Q: - return self._build_q("gte", value) + return self._build_q("gte", "gte", value) def lt(self, value: T) -> Q: - return self._build_q("lt", value) + return self._build_q("lt", "lt", value) def lte(self, value: T) -> Q: - return self._build_q("lte", value) + return self._build_q("lte", "lte", value) def is_null(self, value: bool = True) -> Q: - return self._build_q("isnull", value) + return self._build_q("is_null", "isnull", value) def is_in(self, values: Iterable[T]) -> Q: - return self._build_q("in", values) - - if TYPE_CHECKING: - # Pattern conditions are implemented by StringConditionsMixin, not - # here -- the runtime surface stays exactly as narrow as the field it - # belongs to, which is what where() traversal reflects. They are - # *declared* here so they survive the `Field[T]` annotation models - # carry: a model field's declared type is `Field[str]`, not - # `TextField[str]`, so the checker only sees what `Field` offers. The - # `self` annotation keeps the restriction -- a `Field[int]` still - # rejects `.startswith(...)`. Every field that satisfies this `self` - # type has to mix in StringConditionsMixin, or the declaration - # promises a method the instance doesn't have. - def contains(self: Field[str] | Field[str | None], value: str) -> Q: ... + return self._build_q("is_in", "in", values) + + # Pattern conditions. They live on Field, like every other condition, so + # they survive the `Field[T]` annotation models carry -- a field's declared + # type is `Field[str]`, not `TextField[str]`, so the checker only ever sees + # what `Field` offers. The `self` annotation is the restriction: a + # `Field[int]` rejects `.startswith(...)` statically, and `_build_q` + # rejects it at runtime for anything that doesn't register the lookup. + # + # (`JSONField` does register a jsonb `contains`, so `Field[dict].contains` + # is rejected by the `self` type rather than at runtime -- the same + # type-first guard the encrypted fields use.) + def contains(self: Field[str] | Field[str | None], value: str) -> Q: + return self._build_q("contains", "contains", value) - def icontains(self: Field[str] | Field[str | None], value: str) -> Q: ... + def icontains(self: Field[str] | Field[str | None], value: str) -> Q: + return self._build_q("icontains", "icontains", value) - def startswith(self: Field[str] | Field[str | None], value: str) -> Q: ... + def startswith(self: Field[str] | Field[str | None], value: str) -> Q: + return self._build_q("startswith", "startswith", value) - def endswith(self: Field[str] | Field[str | None], value: str) -> Q: ... + def endswith(self: Field[str] | Field[str | None], value: str) -> Q: + return self._build_q("endswith", "endswith", value) - def _build_q(self, suffix: str, value: Any) -> Q: + def _build_q(self, method: str, suffix: str, value: Any) -> Q: """Build a Q from a lookup suffix + value. Uses Q's positional-tuple constructor to bypass its reserved `_connector`/`_negated` kwargs that - confuse the type checker on `**{name: value}` expansion.""" - assert self.name is not None, ( + confuse the type checker on `**{name: value}` expansion. + + `method` is the condition method's own name, used only for error + messages -- it's what the caller wrote, so it's what an error should + name. + """ + assert self.name, ( "Field name must be set before building a query condition; " "the field must be attached to a model." ) - name = f"{self.name}__{suffix}" if suffix else self.name + if value is None and suffix in _ORDERING_SUFFIXES: + # On a nullable field `T` includes None, so `age.gte(None)` gets + # past the type checker -- there is no way to subtract None from a + # TypeVar, so `self: Field[X | None], value: X` still solves X as + # `int | None` under both ty and pyright. Refuse here instead, at + # the call site, rather than letting it reach the compiler as a + # "Cannot use None as a query value" ValueError with no field in it. + raise TypeError( + f"{type(self).__name__} {self.name!r}: .{method}() has no " + f"meaning for None -- a SQL comparison against NULL is never " + f"true. Use .is_null() instead." + ) + if suffix and not self.get_lookup(suffix): + # The type checker rejects most of these already (a `Field[int]` + # has no `.startswith`); this catches what it can't see. + raise TypeError( + f"{type(self).__name__} {self.name!r} does not support " + f".{method}() -- no {suffix!r} lookup is registered for it." + ) + name = f"{self.name}{LOOKUP_SEP}{suffix}" if suffix else self.name return Q((name, value)) + def with_lookup_prefix(self, prefix: str) -> Self: + """Return a detached copy of this field whose name carries `prefix`. + + This is all where() traversal needs: `Child.parent.name` hands back the + related model's own `name` field renamed to `parent__name`, so the + field's own condition methods build `Q(parent__name=...)`. Nothing has + to re-implement or rewrite the field's surface, which is why a + traversed field offers exactly what direct access offers -- including + an encrypted field's blocks, whose error message names the full path. + + The copy is not attached to a model and exists only to build a Q. + """ + prefixed = copy.copy(self) + prefixed.name = f"{prefix}{LOOKUP_SEP}{self.name}" + return prefixed + def preflight(self, **kwargs: Any) -> list[PreflightResult]: return [*self._check_field_name()] @@ -450,22 +499,16 @@ def contribute_to_class(self, cls: type[Model], name: str) -> None: # Descriptor protocol implementation # - # The first two overloads are class access on a *model-valued* field -- a - # foreign key. They yield `type[T]` rather than the descriptor so the - # related model's own typed field surface is reachable for where() - # traversal (`Child.parent.name.equals(...)`), matching what - # `ForwardForeignKeyDescriptor.__get__` returns at runtime (a - # `RelatedFieldRef` proxy onto the related model). They come first so they - # win over the plain `Self` overload for FK fields; a non-model T never - # matches them. - @overload - def __get__[M: Model]( - self: Field[M], instance: None, owner: type[Model] - ) -> type[M]: ... - + # The first overload is class access on a *model-valued* field -- a foreign + # key, nullable or not. It yields `type[T]` rather than the descriptor so + # the related model's own typed field surface is reachable for where() + # traversal (`Child.parent.name.equals(...)`), matching the traversal + # `ForwardForeignKeyDescriptor.__getattr__` serves at runtime. It comes + # first so it wins over the plain `Self` overload for FK fields; a + # non-model T never matches it. @overload def __get__[M: Model]( - self: Field[M | None], instance: None, owner: type[Model] + self: Field[M] | Field[M | None], instance: None, owner: type[Model] ) -> type[M]: ... @overload @@ -615,6 +658,24 @@ def value_from_object(self, obj: Model) -> T | None: return getattr(obj, self.name) +# The condition methods `Field` exposes, named once. Anything that needs the +# set rather than the methods themselves -- the relation-traversal advice in +# related_typed.py, the tests that sweep the surface -- imports from here +# instead of keeping its own copy in sync. +STRING_CONDITION_METHODS = ("contains", "icontains", "startswith", "endswith") +CONDITION_METHODS = ( + "equals", + "not_equal", + "gt", + "gte", + "lt", + "lte", + "is_null", + "is_in", + *STRING_CONDITION_METHODS, +) + + def validate_none_only_default( field: Field[Any], default: Any, *, allow_null: bool ) -> None: @@ -644,41 +705,6 @@ def validate_none_only_default( raise TypeError(f"{name}(default=None) requires allow_null=True.") -class StringConditionsMixin: - """The pattern conditions that every string-valued field carries. - - `Field` *declares* these under TYPE_CHECKING, restricted by their `self` - annotation to `Field[str]` / `Field[str | None]`, so they survive the - `Field[T]` annotation models write. This is where they are implemented, - and the two have to agree: a field whose value type is `str` must mix this - in, or the declaration promises a method that raises `AttributeError`. - - That means TextField and its subclasses, and also the string-valued fields - that are *not* TextFields -- `RandomStringField` (a ColumnField) and - `GenericIPAddressField` (a DefaultableField). Non-string fields get - neither the declaration nor the methods, which is what keeps where() - traversal's surface an honest mirror of direct field access. - - Must be used with Field as a co-base class. - """ - - if TYPE_CHECKING: - # Provided by Field, the required co-base class. - def _build_q(self, suffix: str, value: Any) -> Q: ... - - def contains(self, value: str) -> Q: - return self._build_q("contains", value) - - def icontains(self, value: str) -> Q: - return self._build_q("icontains", value) - - def startswith(self, value: str) -> Q: - return self._build_q("startswith", value) - - def endswith(self, value: str) -> Q: - return self._build_q("endswith", value) - - class ColumnField[T](Field[T]): """Base for fields backed by a column value (required/allow_null/validators).""" diff --git a/plain-postgres/plain/postgres/fields/encrypted.py b/plain-postgres/plain/postgres/fields/encrypted.py index d89cf56e2d..d1db765823 100644 --- a/plain-postgres/plain/postgres/fields/encrypted.py +++ b/plain-postgres/plain/postgres/fields/encrypted.py @@ -31,6 +31,7 @@ from plain.postgres.connection import DatabaseConnection from plain.postgres.lookups import Lookup, Transform + from plain.postgres.query_utils import Q from plain.preflight.results import PreflightResult __all__ = [ @@ -110,8 +111,8 @@ def _decrypt(value: str) -> str: # Shared tail explaining why encrypted fields reject value comparisons — used -# by both the exact-lookup guard (_EncryptedExact) and the typed-query method -# guard (_lookup_unsupported_message). +# by every refusal, which all route through +# `EncryptedField._lookup_unsupported_message`. _NON_DETERMINISTIC_EXPLANATION = ( "ciphertext is non-deterministic. Use .is_null() instead." ) @@ -128,11 +129,14 @@ class _EncryptedExact(Exact): def __init__(self, lhs: Any, rhs: Any) -> None: if rhs is not None: - target = getattr(lhs, "target", None) - field_name = getattr(target, "name", None) or "" + # lhs.output_field is the encrypted field itself (the lookups.py + # idiom). Its own sentence, not _lookup_unsupported_message's: + # `filter()` *is* supported here, just not against a value, and + # saying "does not support .filter()" would be wrong. Both share + # the explanation tail below. raise TypeError( - f"Encrypted field {field_name!r} cannot be filtered by " - f"equality against a non-None value — " + f"Encrypted field {lhs.output_field.name!r} cannot be filtered " + f"by equality against a non-None value — " f"{_NON_DETERMINISTIC_EXPLANATION}" ) super().__init__(lhs, rhs) @@ -159,6 +163,15 @@ class EncryptedField[T](Field[T]): (``TextField``, ``JSONField``), which supplies the column behavior. """ + def __init__(self, **kwargs: Any) -> None: + # Present only for the type checker. ty resolves `super().__init__` in + # EncryptedJSONField through this class and lands on `Field.__init__`, + # which takes no arguments, so without a signature here it rejects the + # kwargs the concrete field forwards. At runtime this is a plain + # cooperative passthrough and the MRO would reach the concrete field + # either way. + super().__init__(**kwargs) + # The complete lookup surface, replacing the base field's registry. # isnull is obviously needed. exact is required so that `filter(field=None)` # works — the ORM resolves "exact" first and then rewrites None to isnull. @@ -170,10 +183,6 @@ class EncryptedField[T](Field[T]): # get_lookup()/get_transform() and registry consumers (e.g. # unsupported-lookup error suggestions) all resolve through this one dict. # A classmethod so both class-level and instance-level callers work. - def __init__(self, **kwargs: Any) -> None: - # Cooperative passthrough to the concrete field this is mixed with. - super().__init__(**kwargs) - @classmethod def get_lookups(cls) -> dict[str, type[Lookup | Transform]]: return {"exact": _EncryptedExact, "isnull": IsNull} @@ -183,54 +192,52 @@ def get_transform(self, name: str) -> Callable[..., Transform] | None: # name — key transforms would operate on ciphertext, so block them. return None - # Block typed-query comparison methods. Ciphertext is non-deterministic, - # so equality/ordering against a Python value can't match anything - # meaningful. The parameter type is `Never` so a type checker rejects any - # call site; the runtime raises if someone bypasses the type checker. - # Return type is `Never` (not `Q`) to reflect that control never returns — - # `Never` is assignable to `Q` so `where(field.equals(...))` still - # type-checks at the use site, and the parameter error is the one that - # surfaces. - def equals(self, value: Never) -> Never: # ty: ignore[invalid-method-override] - raise TypeError(self._lookup_unsupported_message("equals")) + def _build_q(self, method: str, suffix: str, value: Any) -> Q: + """The one runtime guard. Every condition method on `Field` funnels + through here, so blocking the ones that compare ciphertext takes a + single override -- including conditions that don't exist yet. + + `isnull` is the only meaningful comparison: it reads the column's + NULL-ness, not its contents. + """ + if suffix != "isnull": + raise TypeError(self._lookup_unsupported_message(method)) + return super()._build_q(method, suffix, value) + + if TYPE_CHECKING: + # The static half of the same block. `Never` as the parameter type + # rejects every call site; the return is `Never` (not `Q`) to reflect + # that control never returns, and `Never` is assignable to `Q` so + # `where(field.equals(...))` still type-checks at the use site with the + # parameter error as the one that surfaces. + # + # Declarations only -- `_build_q` above is what raises. Keeping them + # here means the static block and the runtime block can't drift into + # disagreeing about *how* to refuse, only about which methods exist. + def equals(self, value: Never) -> Never: ... # ty: ignore[invalid-method-override] - def not_equal(self, value: Never) -> Never: # ty: ignore[invalid-method-override] - raise TypeError(self._lookup_unsupported_message("not_equal")) + def not_equal(self, value: Never) -> Never: ... # ty: ignore[invalid-method-override] - def gt(self, value: Never) -> Never: # ty: ignore[invalid-method-override] - raise TypeError(self._lookup_unsupported_message("gt")) + def gt(self, value: Never) -> Never: ... # ty: ignore[invalid-method-override] - def gte(self, value: Never) -> Never: # ty: ignore[invalid-method-override] - raise TypeError(self._lookup_unsupported_message("gte")) + def gte(self, value: Never) -> Never: ... # ty: ignore[invalid-method-override] - def lt(self, value: Never) -> Never: # ty: ignore[invalid-method-override] - raise TypeError(self._lookup_unsupported_message("lt")) + def lt(self, value: Never) -> Never: ... # ty: ignore[invalid-method-override] - def lte(self, value: Never) -> Never: # ty: ignore[invalid-method-override] - raise TypeError(self._lookup_unsupported_message("lte")) + def lte(self, value: Never) -> Never: ... # ty: ignore[invalid-method-override] - def is_in(self, values: Never) -> Never: # ty: ignore[invalid-method-override] - raise TypeError(self._lookup_unsupported_message("is_in")) + def is_in(self, values: Never) -> Never: ... # ty: ignore[invalid-method-override] - # The pattern conditions are declared on Field (implemented on TextField) - # and are as meaningless on ciphertext as the comparisons above, so they - # are blocked in the same shape: `Never` rejects the call site, the raise - # covers anyone who bypasses the type checker. EncryptedJSONField never - # had them at runtime; blocking here costs it nothing. - def contains(self, value: Never) -> Never: # ty: ignore[invalid-method-override] - raise TypeError(self._lookup_unsupported_message("contains")) + def contains(self, value: Never) -> Never: ... # ty: ignore[invalid-method-override] - def icontains(self, value: Never) -> Never: # ty: ignore[invalid-method-override] - raise TypeError(self._lookup_unsupported_message("icontains")) + def icontains(self, value: Never) -> Never: ... # ty: ignore[invalid-method-override] - def startswith(self, value: Never) -> Never: # ty: ignore[invalid-method-override] - raise TypeError(self._lookup_unsupported_message("startswith")) + def startswith(self, value: Never) -> Never: ... # ty: ignore[invalid-method-override] - def endswith(self, value: Never) -> Never: # ty: ignore[invalid-method-override] - raise TypeError(self._lookup_unsupported_message("endswith")) + def endswith(self, value: Never) -> Never: ... # ty: ignore[invalid-method-override] def _lookup_unsupported_message(self, method: str) -> str: - assert self.name is not None, ( + assert self.name, ( "Encrypted field must be attached to a model before its typed-query " "methods can produce a meaningful error message." ) @@ -286,14 +293,7 @@ def _check_encrypted_constraints(self) -> list[PreflightResult]: return errors -# `EncryptedField` narrows the pattern conditions `TextField` implements down to -# `Never` — that narrowing is the type-level block, and it is exactly what the -# base-class-conflict check objects to, so the diagnostic is suppressed here. -# The suppression is class-wide, so any OTHER base-class conflict introduced on -# this class has to be checked by hand. -class EncryptedTextField[T: (str, str | None) = str]( # ty: ignore[invalid-method-override] - EncryptedField[T], TextField[T] -): +class EncryptedTextField[T: (str, str | None) = str](EncryptedField[T], TextField[T]): """A TextField that encrypts its value before storing in the database. Values are encrypted using Fernet (AES-128-CBC + HMAC-SHA256) with a key diff --git a/plain-postgres/plain/postgres/fields/network.py b/plain-postgres/plain/postgres/fields/network.py index 354e1c9618..a03c5c215b 100644 --- a/plain-postgres/plain/postgres/fields/network.py +++ b/plain-postgres/plain/postgres/fields/network.py @@ -10,15 +10,13 @@ from plain import exceptions -from .base import NOT_PROVIDED, DefaultableField, StringConditionsMixin +from .base import NOT_PROVIDED, DefaultableField if TYPE_CHECKING: from plain.postgres.connection import DatabaseConnection -class GenericIPAddressField[T: (str, str | None) = str]( - StringConditionsMixin, DefaultableField[T] -): +class GenericIPAddressField[T: (str, str | None) = str](DefaultableField[T]): db_type_sql = "inet" empty_strings_allowed = False diff --git a/plain-postgres/plain/postgres/fields/related_typed.py b/plain-postgres/plain/postgres/fields/related_typed.py index 638089089b..14c30cc6e5 100644 --- a/plain-postgres/plain/postgres/fields/related_typed.py +++ b/plain-postgres/plain/postgres/fields/related_typed.py @@ -1,77 +1,39 @@ """Typed FK traversal for the where() query API. When `Order.user` is a ForeignKey, accessing `.email` at the class level (as in -`where(Order.user.email.equals("x"))`) needs to produce -`Q(user__email="x")` so the existing SQL builder's join machinery resolves the -right column. `ForwardForeignKeyDescriptor.__get__` returns a `RelatedFieldRef` -for class-level access, and these two helpers walk attribute access into the -related model to build the lookup path. +`where(Order.user.email.equals("x"))`) needs to produce `Q(user__email="x")` so +the existing SQL builder's join machinery resolves the right column. + +The whole mechanism is `Field.with_lookup_prefix`: walking into the related +model hands back that model's own field, renamed to carry the relation path, so +its own condition methods build the right keys. Nothing here re-implements or +rewrites a field's surface -- which is why a traversed field offers exactly +what direct access offers, down to an encrypted field's blocks. """ from __future__ import annotations from typing import TYPE_CHECKING, Any +from plain.postgres.constants import LOOKUP_SEP from plain.postgres.exceptions import FieldDoesNotExist +from plain.postgres.fields.base import CONDITION_METHODS from plain.postgres.fields.related import ForeignKeyField -from plain.postgres.query_utils import Q if TYPE_CHECKING: from plain.postgres.base import Model - from plain.postgres.fields.base import Field - - -# Condition-method names a traversed field exposes. Traversal offers exactly the -# surface the field itself offers: an attribute outside this set is not a -# condition method and raises AttributeError rather than silently building a -# wrong-shaped Q. -_CONDITION_METHODS = frozenset( - { - "equals", - "not_equal", - "gt", - "gte", - "lt", - "lte", - "is_null", - "is_in", - "contains", - "icontains", - "startswith", - "endswith", - } -) - - -def _prefix_q(q: Q, parent_path: str) -> Q: - """Prepend `parent_path__` to every leaf lookup key in a Q tree, in place. - - A leaf child is a `(key, value)` tuple; a nested Q node is recursed into. - Negation and connector are left untouched — only the lookup keys change, - turning a Q built against a field's bare name into one whose keys carry the - full relation path. - """ - for i, child in enumerate(q.children): - if isinstance(child, Q): - _prefix_q(child, parent_path) - else: - key, value = child - q.children[i] = (f"{parent_path}__{key}", value) - return q class RelatedFieldRef: """Class-level proxy that walks attribute access into the related model and accumulates the lookup-path prefix as it goes. - Returned by `ForwardForeignKeyDescriptor.__get__` for the first hop; chained - traversal (`Order.user.profile.city`) builds nested `RelatedFieldRef` - instances until a concrete field is reached, then a `PrefixedFieldRef`. + Chained traversal (`Order.user.profile.city`) builds nested + `RelatedFieldRef` instances until a concrete field is reached, which comes + back as a prefixed copy of that field. Names resolve through the related model's metadata (`get_forward_field`), - not attribute lookup, so a related field whose name collides with a public - attribute on the FK descriptor (`field`, `is_cached`, `get_queryset`, …) - still resolves to that field. + not attribute lookup, so a related field keeps resolving to the field. A relation is not itself a field, so it carries no condition methods. `Child.parent.equals(obj)` is spelled `Child.parent.id.equals(obj.id)` -- @@ -80,8 +42,8 @@ class RelatedFieldRef: over by adding the methods here: to the type checker `Child.parent` is `type[Parent]` (see `Field.__get__`'s model-valued overloads), which is what makes chained traversal type-check, and a runtime method the checker - rejects would be worse than no method at all. `__getattr__` raises a - TypeError pointing at the right spelling instead. + rejects would be worse than no method at all. `__getattr__` raises an + AttributeError pointing at the right spelling instead. """ def __init__(self, model: type[Model], prefix: str, target_name: str) -> None: @@ -109,9 +71,10 @@ def __getattr__(self, name: str) -> Any: except FieldDoesNotExist: # The field lookup comes first so a related model that really does # have a column named `equals` (or `contains`, …) still traverses - # to it -- same rule as the descriptor-attribute shadowing above. - if name in _CONDITION_METHODS: - raise TypeError( + # to it. An AttributeError, not a TypeError, so `hasattr` and + # `getattr(..., default)` keep behaving. + if name in CONDITION_METHODS: + raise AttributeError( f"{self._prefix}.{name}() is not available: " f"{self._prefix!r} is a relation, not a field. Build the " f"condition on the key it points at instead -- " @@ -125,40 +88,7 @@ def __getattr__(self, name: str) -> Any: if isinstance(field, ForeignKeyField): return RelatedFieldRef( model=field.remote_field.model, - prefix=f"{self._prefix}__{name}", + prefix=f"{self._prefix}{LOOKUP_SEP}{name}", target_name=field.target_field.name, ) - return PrefixedFieldRef(field=field, parent_path=self._prefix) - - -class PrefixedFieldRef: - """A field-like reference that rewrites the wrapped field's own Q conditions - onto a multi-segment lookup path, so chained access reads identically to - direct access: - - Order.user.email.equals("x") # PrefixedFieldRef(email_field, "user") - Order.email.equals("x") # TextField on Order - - A condition call delegates to the wrapped field's own method (which builds a - Q against the field's bare name, or raises — e.g. an encrypted field), then - prefixes every leaf key in that Q with the parent relation path. The - traversed surface is therefore exactly the field's own surface: a method the - field doesn't define raises AttributeError, same as direct access. - """ - - def __init__(self, field: Field, parent_path: str) -> None: - self._field = field - self._parent_path = parent_path - - def __repr__(self) -> str: - return f"" - - def __getattr__(self, name: str) -> Any: - if name not in _CONDITION_METHODS: - raise AttributeError(name) - field_method = getattr(self._field, name) - - def build(*args: Any, **kwargs: Any) -> Q: - return _prefix_q(field_method(*args, **kwargs), self._parent_path) - - return build + return field.with_lookup_prefix(self._prefix) diff --git a/plain-postgres/plain/postgres/fields/text.py b/plain-postgres/plain/postgres/fields/text.py index ed35f75132..83f027a0a7 100644 --- a/plain-postgres/plain/postgres/fields/text.py +++ b/plain-postgres/plain/postgres/fields/text.py @@ -8,13 +8,13 @@ from plain import validators -from .base import NOT_PROVIDED, ChoicesField, ColumnField, StringConditionsMixin +from .base import NOT_PROVIDED, ChoicesField, ColumnField if TYPE_CHECKING: from plain.postgres.functions.random import RandomString -class TextField[T: (str, str | None) = str](StringConditionsMixin, ChoicesField[T]): +class TextField[T: (str, str | None) = str](ChoicesField[T]): db_type_sql = "text" def __init__( @@ -96,9 +96,7 @@ class URLField[T: (str, str | None) = str](TextField[T]): default_validators = (validators.URLValidator(),) -class RandomStringField[T: (str, str | None) = str]( - StringConditionsMixin, ColumnField[T] -): +class RandomStringField[T: (str, str | None) = str](ColumnField[T]): """Text column whose value is a Postgres-generated random hex string. The column carries a ``DEFAULT`` that evaluates per row, so raw SQL and diff --git a/plain-postgres/tests/public/test_typed_where.py b/plain-postgres/tests/public/test_typed_where.py index 8d6eb01073..a90a4d9498 100644 --- a/plain-postgres/tests/public/test_typed_where.py +++ b/plain-postgres/tests/public/test_typed_where.py @@ -15,8 +15,6 @@ from plain.postgres import Field from plain.postgres.query_utils import Q -PATTERN_CONDITIONS = ["contains", "icontains", "startswith", "endswith"] - def test_class_access_yields_typed_descriptors() -> None: """Class-level field access returns the descriptor, parameterized by T. @@ -87,6 +85,9 @@ def _typed_check_string_conditions_are_string_only() -> None: DefaultsExample.name.startswith("a") DefaultsExample.note.contains("a") DefaultsExample.priority.startswith("a") # ty: ignore[invalid-argument-type] + DefaultsExample.priority.contains("a") # ty: ignore[invalid-argument-type] + DefaultsExample.priority.icontains("a") # ty: ignore[invalid-argument-type] + DefaultsExample.priority.endswith("a") # ty: ignore[invalid-argument-type] def test_field_methods_return_q_objects(): @@ -192,44 +193,13 @@ def test_is_null_with_explicit_default(db): # --------------------------------------------------------------------------- -# Pattern conditions on string-valued fields that aren't TextFields. -# -# `Field` declares contains/icontains/startswith/endswith for every -# string-valued field (that's what carries them through the `Field[str]` -# annotation models write), so every string-valued field has to implement them. -# `GenericIPAddressField` is a DefaultableField and `RandomStringField` is a -# ColumnField — neither inherits TextField's implementations, so both mix in -# the shared implementation instead. Without it the declaration promises a -# method that raises AttributeError. +# Pattern conditions live on `Field`, restricted to string-valued fields by +# their `self` annotation. So they reach every string-valued field regardless +# of its base class — `GenericIPAddressField` is a DefaultableField and +# `RandomStringField` is a ColumnField, and neither inherits TextField. # --------------------------------------------------------------------------- -@pytest.mark.parametrize("method", PATTERN_CONDITIONS) -def test_ip_address_field_pattern_conditions_build_q(method: str) -> None: - q = getattr(StringConditionsExample.ip, method)("10.") - assert q.children == [(f"ip__{method}", "10.")] - - -@pytest.mark.parametrize("method", PATTERN_CONDITIONS) -def test_random_string_field_pattern_conditions_build_q(method: str) -> None: - q = getattr(StringConditionsExample.token, method)("ab") - assert q.children == [(f"token__{method}", "ab")] - - -def test_ip_address_field_pattern_conditions_type_check() -> None: - """The same calls spelled statically, so ty checks them rather than going - through getattr. `assert_type` pins the return so a silently-Any surface - would fail here.""" - assert_type(StringConditionsExample.ip.contains("0.0"), Q) - assert_type(StringConditionsExample.ip.icontains("0.0"), Q) - assert_type(StringConditionsExample.ip.startswith("10."), Q) - assert_type(StringConditionsExample.ip.endswith(".1"), Q) - assert_type(StringConditionsExample.token.contains("ab"), Q) - assert_type(StringConditionsExample.token.icontains("ab"), Q) - assert_type(StringConditionsExample.token.startswith("ab"), Q) - assert_type(StringConditionsExample.token.endswith("yz"), Q) - - def test_where_filters_ip_address_field_by_pattern(db): StringConditionsExample.query.create(label="private", ip="10.0.0.1") StringConditionsExample.query.create(label="local", ip="192.168.1.1") @@ -257,13 +227,43 @@ def test_where_filters_random_string_field_by_pattern(db): assert [r.label for r in rows] == ["only"] -def test_non_string_fields_have_no_pattern_conditions() -> None: - """The runtime surface has to stay as narrow as the declaration: a field - whose value type isn't a string gets neither. This is also what keeps - where() traversal an honest mirror of direct field access.""" - for method in PATTERN_CONDITIONS: - assert hasattr(StringConditionsExample.label, method) - assert hasattr(StringConditionsExample.ip, method) - assert hasattr(StringConditionsExample.token, method) - assert not hasattr(StringConditionsExample.id, method) - assert not hasattr(DefaultsExample.priority, method) +def test_pattern_condition_on_a_non_string_field_is_a_static_error() -> None: + """The `self` annotation is the whole guard, and it is a static one — the + load-bearing `ty: ignore` markers in the TYPE_CHECKING block above are what + pin it. + + There is deliberately no runtime counterpart: `Contains`, `IContains`, + `StartsWith` and `EndsWith` are registered on `Field` itself (see + lookups.py), so every field has them and nothing at runtime distinguishes a + string field from an int one. `IntegerField.contains("9")` builds a valid + `priority__contains` lookup that Postgres will happily run. Same type-first + guard model the encrypted fields use. + """ + assert DefaultsExample.priority.get_lookup("contains") is not None + + +# --------------------------------------------------------------------------- +# None operands on ordering conditions. +# +# There is deliberately no static pin here. On a nullable field `T` includes +# None, and None cannot be subtracted from a TypeVar: probed against ty 0.0.80 +# and pyright 1.1.414, `self: Field[X | None], value: X` selects the intended +# overload but solves X as `int | None`, so `.gte(None)` type-checks either +# way. The runtime refusal below is the guard. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("method", ["gt", "gte", "lt", "lte"]) +def test_ordering_condition_rejects_none(method: str) -> None: + with pytest.raises(TypeError, match=rf"\.{method}\(\) has no meaning for None"): + getattr(DefaultsExample.note, method)(None) + + +def test_ordering_condition_still_accepts_a_value_on_a_nullable_field() -> None: + assert DefaultsExample.note.gte("m").children == [("note__gte", "m")] + + +def test_is_null_and_equals_still_accept_none() -> None: + """`equals(None)` is the ORM's exact-None rewrite and stays legal.""" + assert DefaultsExample.note.equals(None).children == [("note", None)] + assert DefaultsExample.note.is_null().children == [("note__isnull", True)] diff --git a/plain-postgres/tests/public/test_typed_where_fk.py b/plain-postgres/tests/public/test_typed_where_fk.py index db1a52299b..5036418547 100644 --- a/plain-postgres/tests/public/test_typed_where_fk.py +++ b/plain-postgres/tests/public/test_typed_where_fk.py @@ -1,8 +1,8 @@ """Typed where() across forward foreign-key relations. `ChildCascade.parent` is a ForeignKeyField to DeleteParent. Accessing -`.name` on the class-level descriptor should yield a PrefixedFieldRef -whose typed-query methods build Q objects with `parent__name` paths. +`.name` on the class-level descriptor should yield DeleteParent's own `name` +field, renamed to `parent__name`, so its condition methods build that path. """ from __future__ import annotations @@ -19,6 +19,7 @@ from app.examples.models.encrypted import SecretStore from app.examples.models.relationships import Tag, Widget, WidgetTag from app.examples.models.shadowing import ShadowSource, ShadowTarget +from plain.postgres.fields.base import CONDITION_METHODS from plain.postgres.query_utils import Q @@ -154,94 +155,50 @@ def test_two_hop_chain_combines_with_or(db): class TestEncryptedFieldTraversalBlocked: - """A model with an FK to an encrypted-field-bearing model. Direct access - (SecretStore.api_key.equals) raises TypeError. Traversal must too — - otherwise the typed-API guard becomes a per-call-site instead of a - per-field guarantee.""" - - def test_traversed_equals_raises(self, db): - # WidgetTag doesn't have an FK to SecretStore, so we construct a - # synthetic traversal via PrefixedFieldRef directly. This is the - # same code path Order.relation.api_key.equals(...) would use. - from plain.postgres.fields.related_typed import PrefixedFieldRef - - ref = PrefixedFieldRef( - field=SecretStore._model_meta.get_forward_field("api_key"), - parent_path="store", + """Traversal hands back the field itself, so an encrypted field's blocks + arrive with it — the guard is per-field, not per-call-site. The error even + names the full path, because the prefixed copy carries it as its name.""" + + @pytest.fixture + def traversed(self): + # No model in the examples app has an FK to SecretStore, so prefix the + # field directly. This is exactly what RelatedFieldRef hands back. + return SecretStore._model_meta.get_forward_field("api_key").with_lookup_prefix( + "store" ) - with pytest.raises( - TypeError, match=r"Encrypted field.*does not support \.equals\(" - ): - ref.equals("x") - - def test_traversed_ordering_raises(self): - from plain.postgres.fields.related_typed import PrefixedFieldRef - - ref = PrefixedFieldRef( - field=SecretStore._model_meta.get_forward_field("api_key"), - parent_path="store", - ) - for method in ("not_equal", "gt", "gte", "lt", "lte"): - with pytest.raises(TypeError, match=rf"does not support \.{method}\("): - getattr(ref, method)("x") @pytest.mark.parametrize( - "method", ["contains", "icontains", "startswith", "endswith"] + "method", + [m for m in CONDITION_METHODS if m != "is_null"], ) - def test_traversed_text_method_raises(self, method): - # EncryptedTextField inherits TextField's pattern conditions and blocks - # them, so traversal reports the same TypeError direct access does. - from plain.postgres.fields.related_typed import PrefixedFieldRef - - ref = PrefixedFieldRef( - field=SecretStore._model_meta.get_forward_field("api_key"), - parent_path="store", - ) - with pytest.raises(TypeError, match=rf"does not support \.{method}\("): - getattr(ref, method)("x") - - def test_traversed_is_in_raises(self): - from plain.postgres.fields.related_typed import PrefixedFieldRef - - ref = PrefixedFieldRef( - field=SecretStore._model_meta.get_forward_field("api_key"), - parent_path="store", - ) - with pytest.raises(TypeError, match=r"does not support \.is_in\("): - ref.is_in(["x", "y"]) - - def test_traversed_is_null_still_works(self): - from plain.postgres.fields.related_typed import PrefixedFieldRef + def test_traversed_condition_raises(self, traversed, method): + with pytest.raises( + TypeError, match=rf"store__api_key.*does not support \.{method}\(" + ): + getattr(traversed, method)("x") - ref = PrefixedFieldRef( - field=SecretStore._model_meta.get_forward_field("api_key"), - parent_path="store", - ) - q = ref.is_null() - assert q.children == [("store__api_key__isnull", True)] + def test_traversed_is_null_still_works(self, traversed): + assert traversed.is_null().children == [("store__api_key__isnull", True)] -def test_traversed_surface_matches_direct_field_surface(): - """Traversal exposes exactly the field's own condition surface. A text-only - method (.contains) is present when traversing to a TextField and absent when - traversing to a non-text field — the same as direct field access.""" - from plain.postgres.fields.related_typed import _CONDITION_METHODS +def test_traversal_hands_back_the_field_itself(): + """The traversed object is the related model's own field, renamed — which + is what makes its surface identical to direct access by construction.""" + direct = DeleteParent._model_meta.get_forward_field("name") + traversed = ChildCascade.parent.name - for field_name in ("name", "id"): - direct = DeleteParent._model_meta.get_forward_field(field_name) - traversed = getattr(ChildCascade.parent, field_name) - for method in _CONDITION_METHODS: - assert hasattr(traversed, method) == hasattr(direct, method), ( - f"{field_name}.{method}" - ) + assert type(traversed) is type(direct) + assert traversed.name == "parent__name" + assert direct.name == "name" # the original is untouched # --------------------------------------------------------------------------- -# Descriptor attribute shadowing: a related field whose name collides with a -# public attribute on ForwardForeignKeyDescriptor (`field`, `is_cached`, -# `get_queryset`, `get_prefetch_queryset`) must still traverse to the field. -# `__get__` returns a RelatedFieldRef proxy for class access, so the -# descriptor's own attributes are unreachable through the relation. +# Descriptor attribute shadowing: these four names were once public attributes +# on ForwardForeignKeyDescriptor, so a related field named after one of them +# resolved to the descriptor's attribute instead of traversing. They are +# `_`-prefixed now, and `Meta` skips `_`-prefixed attributes when it collects +# fields, so no field name can ever collide with a descriptor attribute again. +# These cases pin that: re-publishing any of them would fail here. # --------------------------------------------------------------------------- @@ -351,14 +308,12 @@ def test_where_filters_by_null_relation_key(db): assert [r.id for r in non_nulls] == [attached.id] -@pytest.mark.parametrize( - "method", - ["equals", "not_equal", "gt", "gte", "lt", "lte", "is_null", "is_in", "contains"], -) +@pytest.mark.parametrize("method", CONDITION_METHODS) def test_condition_on_the_relation_itself_raises_helpful_error(method): - """Not the generic AttributeError -- the error has to name the spelling - that works, or the constraint just looks like a missing feature.""" - with pytest.raises(TypeError) as excinfo: + """An AttributeError, so `hasattr`/`getattr(..., default)` keep working -- + but one that names the spelling that does work, or the constraint just + looks like a missing feature.""" + with pytest.raises(AttributeError) as excinfo: getattr(ChildCascade.parent, method) message = str(excinfo.value) @@ -366,6 +321,11 @@ def test_condition_on_the_relation_itself_raises_helpful_error(method): assert f"parent.id.{method}(...)" in message +def test_condition_on_the_relation_keeps_the_attribute_protocol(): + assert not hasattr(ChildCascade.parent, "equals") + assert getattr(ChildCascade.parent, "equals", "fallback") == "fallback" + + def test_unknown_relation_attribute_still_raises_attribute_error(): """Only the condition names get the TypeError; a genuine typo stays an AttributeError so `hasattr` and friends behave.""" From 89f8a76091d11eed9d9a571fbe8dff3a38101504 Mon Sep 17 00:00:00 2001 From: Dave Gaeddert Date: Fri, 18 Sep 2026 23:23:59 -0500 Subject: [PATCH 19/21] Class-level FK access returns the descriptor again Returning a `RelatedFieldRef` proxy from `__get__` only ever existed to dodge name shadowing: a related model with a column called `field` or `get_queryset` would hit the descriptor's own attribute instead of traversing. The proxy paid for that by breaking the descriptor convention everywhere else -- prefetching and `__reduce__` both had to reach past `__get__` with `inspect.getattr_static`, and every class-level read allocated a proxy and re-executed a function-local import (257 ns, vs 42 ns for returning `self`). Close the hole at the source instead. The descriptor's attributes are now `_`-prefixed (`_field`, `_is_cached`, `_get_queryset`, `_get_prefetch_queryset`), and `Meta` skips `_`-prefixed attributes when it collects fields (meta.py), so a field name can never collide with a descriptor attribute -- shadowing is closed rather than routed around. Traversal moves onto the descriptor as `__getattr__`, which only fires for names the descriptor doesn't define: a leaf field comes back from `with_lookup_prefix`, a further foreign key hands off to `RelatedFieldRef`, which is now needed only for hops beyond the first. `_get_prefetch_queryset` is the duck-typed prefetch protocol, so the two manager classes and the one call site in query.py are renamed with it. Nothing outside plain-postgres references any of these; the managers keep their public `get_queryset`. `RelatedObjectDoesNotExist` stays public on purpose -- it is user-facing and documented. Both `inspect.getattr_static` patches revert, and the pickling test goes with them: the round-trip is covered by the convention it no longer breaks. The shadowing cases stay as a pin on the rename -- re-publishing any of those four names would fail them. Also in this commit: `QuerySet.where()` calls `self.filter(*conditions)` instead of reaching into `_filter_or_exclude`. --- .../postgres/fields/related_descriptors.py | 154 +++++++++++------- .../plain/postgres/fields/related_managers.py | 4 +- plain-postgres/plain/postgres/query.py | 25 ++- plain-postgres/tests/public/test_related.py | 26 --- 4 files changed, 106 insertions(+), 103 deletions(-) diff --git a/plain-postgres/plain/postgres/fields/related_descriptors.py b/plain-postgres/plain/postgres/fields/related_descriptors.py index 04f825edc3..4b8989fa20 100644 --- a/plain-postgres/plain/postgres/fields/related_descriptors.py +++ b/plain-postgres/plain/postgres/fields/related_descriptors.py @@ -36,10 +36,10 @@ class Child(Model): from __future__ import annotations -import inspect from functools import cached_property from typing import Any +from plain.postgres.constants import LOOKUP_SEP from plain.postgres.query import QuerySet from plain.utils.functional import LazyObject @@ -59,38 +59,38 @@ class Child(Model): """ def __init__(self, field_with_rel: Any) -> None: - self.field = field_with_rel + self._field = field_with_rel @cached_property def RelatedObjectDoesNotExist(self) -> type: # The exception can't be created at initialization time since the - # related model might not be resolved yet; `self.field.model` might + # related model might not be resolved yet; `self._field.model` might # still be a string model reference. return type( "RelatedObjectDoesNotExist", - (self.field.remote_field.model.DoesNotExist, AttributeError), + (self._field.remote_field.model.DoesNotExist, AttributeError), { - "__module__": self.field.model.__module__, - "__qualname__": f"{self.field.model.__qualname__}.{self.field.name}.RelatedObjectDoesNotExist", + "__module__": self._field.model.__module__, + "__qualname__": f"{self._field.model.__qualname__}.{self._field.name}.RelatedObjectDoesNotExist", }, ) - def is_cached(self, instance: Any) -> bool: - return self.field.is_cached(instance) + def _is_cached(self, instance: Any) -> bool: + return self._field.is_cached(instance) - def get_queryset(self) -> QuerySet: - qs = self.field.remote_field.model._model_meta.base_queryset + def _get_queryset(self) -> QuerySet: + qs = self._field.remote_field.model._model_meta.base_queryset return qs.all() - def get_prefetch_queryset( + def _get_prefetch_queryset( self, instances: list[Any], queryset: QuerySet | None = None ) -> tuple[QuerySet, Any, Any, bool, str, bool]: if queryset is None: - queryset = self.get_queryset() + queryset = self._get_queryset() - rel_obj_attr = self.field.get_foreign_related_value - instance_attr = self.field.get_local_related_value - related_field = self.field.target_field + rel_obj_attr = self._field.get_foreign_related_value + instance_attr = self._field.get_local_related_value + related_field = self._field.target_field # A foreign key is single-column, so prefetch with a join-less IN query. query = { @@ -103,7 +103,7 @@ def get_prefetch_queryset( rel_obj_attr, instance_attr, True, - self.field.get_cache_name(), + self._field.get_cache_name(), False, ) @@ -117,56 +117,90 @@ def __get__(self, instance: Any | None, cls: type | None = None) -> Any: - ``instance`` is the ``child`` instance - ``cls`` is the ``Child`` class (we don't need it) - Class-level access (``Child.parent``) returns a fresh - ``RelatedFieldRef`` traversal proxy so typed where() can walk into the - related model's fields: - - Child.parent.name.equals("x") → Q(parent__name="x") - - The proxy exposes only traversal machinery, so a related field whose - name collides with one of this descriptor's own attributes (``field``, - ``is_cached``, ``get_queryset`` …) still resolves to the field, not the - descriptor attribute. Framework code that needs the descriptor itself - (prefetching) reaches it with ``inspect.getattr_static`` to bypass this - proxy. + Class-level access (``Child.parent``) returns the descriptor itself, + the ordinary convention. Traversal for typed where() is served by + ``__getattr__`` below rather than by handing back a proxy. """ if instance is None: - from plain.postgres.fields.related_typed import RelatedFieldRef - - return RelatedFieldRef( - model=self.field.remote_field.model, - prefix=self.field.name, - target_name=self.field.target_field.name, - ) + return self # The related object is cached on the model state -- by select_related, # prefetch, the reverse accessor, a prior access, or assignment. try: - rel_obj = self.field.get_cached_value(instance) + rel_obj = self._field.get_cached_value(instance) except KeyError: # _get_raw_value loads the foreign key column on demand if it was # deferred (.only()/.defer()), so we always see the real key here. - pk_value = self.field._get_raw_value(instance) + pk_value = self._field._get_raw_value(instance) rel_obj = None if pk_value is not None: - remote_model = self.field.remote_field.model - target_name = self.field.target_field.name + remote_model = self._field.remote_field.model + target_name = self._field.target_field.name assert target_name is not None # The database FK constraint guarantees the row exists, so build # a partial related instance with only its primary key loaded -- # no query. Accessing any other field triggers the full-row # deferred load. rel_obj = remote_model.from_db([target_name], [pk_value]) - self.field.set_cached_value(instance, rel_obj) + self._field.set_cached_value(instance, rel_obj) # Checked on every access, including a cached None: a non-nullable # foreign key with no value must raise consistently, not just once. - if rel_obj is None and not self.field.allow_null: + if rel_obj is None and not self._field.allow_null: raise self.RelatedObjectDoesNotExist( - f"{self.field.model.__name__} has no {self.field.name}." + f"{self._field.model.__name__} has no {self._field.name}." ) return rel_obj + def __getattr__(self, name: str) -> Any: + """Walk class-level attribute access into the related model, so typed + where() can build joined lookups: + + Child.parent.name.equals("x") → Q(parent__name="x") + + Only names this descriptor doesn't define reach here, and every + attribute it does define is ``_``-prefixed -- which model fields can + never be (``Meta`` skips ``_``-prefixed attributes when it collects + them), so a related field can't be shadowed by descriptor internals. + + A leaf field comes back as a copy of itself carrying the relation + prefix; a further foreign key hands off to ``RelatedFieldRef``, which + accumulates the path for hops beyond the first. + """ + if name.startswith("_"): + # Internals, and anything a field could never be named. + raise AttributeError(name) + + from plain.postgres.exceptions import FieldDoesNotExist + from plain.postgres.fields.base import CONDITION_METHODS + from plain.postgres.fields.related import ForeignKeyField + from plain.postgres.fields.related_typed import RelatedFieldRef + + related_model = self._field.remote_field.model + prefix = self._field.name + try: + field = related_model._model_meta.get_forward_field(name) + except FieldDoesNotExist: + if name in CONDITION_METHODS: + raise AttributeError( + f"{prefix}.{name}() is not available: {prefix!r} is a " + f"relation, not a field. Build the condition on the key it " + f"points at instead -- " + f"{prefix}.{self._field.target_field.name}.{name}(...), " + f"which compiles to the same SQL." + ) from None + raise AttributeError( + f"{prefix}.{name} is not a traversable field or relation" + ) from None + + if isinstance(field, ForeignKeyField): + return RelatedFieldRef( + model=field.remote_field.model, + prefix=f"{prefix}{LOOKUP_SEP}{name}", + target_name=field.target_field.name, + ) + return field.with_lookup_prefix(prefix) + def __set__(self, instance: Any, value: Any) -> None: """ Set the related object (or its raw key) through the forward relation. @@ -183,19 +217,19 @@ def __set__(self, instance: Any, value: Any) -> None: if isinstance(value, LazyObject): value = value if value else None - name = self.field.name + name = self._field.name assert name is not None - remote_field = self.field.remote_field + remote_field = self._field.remote_field if value is None: instance.__dict__[name] = None - self.field.set_cached_value(instance, None) + self._field.set_cached_value(instance, None) return if isinstance(value, remote_field.model): # A related model instance: store its key, cache the object. - instance.__dict__[name] = getattr(value, self.field.target_field.name) - self.field.set_cached_value(instance, value) + instance.__dict__[name] = getattr(value, self._field.target_field.name) + self._field.set_cached_value(instance, value) return if isinstance(value, Model | bool): @@ -203,42 +237,40 @@ def __set__(self, instance: Any, value: Any) -> None: # the key 0/1 via int) -- reject rather than store a bogus key. raise TypeError( f'Cannot assign "{value!r}": ' - f'"{instance.model_options.object_name}.{self.field.name}" must be a ' + f'"{instance.model_options.object_name}.{self._field.name}" must be a ' f'"{remote_field.model.model_options.object_name}" instance or a ' f"primary key value." ) # A bare related key value (e.g. child.parent = 5). - new_value = self.field.to_python(value) + new_value = self._field.to_python(value) # On an actual key change, drop the now-stale forward cache. # Re-storing the same key (e.g. by clean_fields) keeps the cache. - if instance.__dict__.get(name) != new_value and self.field.is_cached(instance): - self.field.delete_cached_value(instance) + if instance.__dict__.get(name) != new_value and self._field.is_cached(instance): + self._field.delete_cached_value(instance) instance.__dict__[name] = new_value def __delete__(self, instance: Any) -> None: """Delete the foreign key value, clearing any cached related object.""" try: - del instance.__dict__[self.field.name] + del instance.__dict__[self._field.name] except KeyError: raise AttributeError( f"{instance.__class__.__name__!r} object has no attribute " - f"{self.field.name!r}" + f"{self._field.name!r}" ) - if self.field.is_cached(instance): - self.field.delete_cached_value(instance) + if self._field.is_cached(instance): + self._field.delete_cached_value(instance) def __reduce__(self) -> tuple[Any, tuple[Any, str]]: """ - Pickling should return the instance attached by self.field on the + Pickling should return the instance attached by self._field on the model, not a new copy of that descriptor. - Reconstruct with ``inspect.getattr_static``, not ``getattr``: class - access runs ``__get__``, which returns a ``RelatedFieldRef`` traversal - proxy, so a plain ``getattr`` would unpickle the descriptor as a proxy. - Same reason the prefetch path reaches for the descriptor statically. + Class access returns the descriptor, so ``getattr`` retrieves the + instance directly from the model. """ - return inspect.getattr_static, (self.field.model, self.field.name) + return getattr, (self._field.model, self._field.name) class ForwardManyToManyDescriptor: diff --git a/plain-postgres/plain/postgres/fields/related_managers.py b/plain-postgres/plain/postgres/fields/related_managers.py index 2533ab1f65..6c307bf417 100644 --- a/plain-postgres/plain/postgres/fields/related_managers.py +++ b/plain-postgres/plain/postgres/fields/related_managers.py @@ -139,7 +139,7 @@ def get_queryset(self) -> QS: queryset = self.model.query return cast(QS, self._apply_rel_filters(queryset)) - def get_prefetch_queryset( + def _get_prefetch_queryset( self, instances: Iterable[Model], queryset: QuerySet | None = None ) -> tuple[ QuerySet, Callable[[Model], Any], Callable[[Model], Any], bool, str, bool @@ -385,7 +385,7 @@ def get_queryset(self) -> QS: queryset = self.model.query return cast(QS, self._apply_rel_filters(queryset)) - def get_prefetch_queryset( + def _get_prefetch_queryset( self, instances: Iterable[Model], queryset: QuerySet | None = None ) -> tuple[ QuerySet, Callable[[Model], Any], Callable[[Model], Any], bool, str, bool diff --git a/plain-postgres/plain/postgres/query.py b/plain-postgres/plain/postgres/query.py index 9c023b2a3b..d44ed5e4ca 100644 --- a/plain-postgres/plain/postgres/query.py +++ b/plain-postgres/plain/postgres/query.py @@ -5,7 +5,6 @@ from __future__ import annotations import copy -import inspect import operator import warnings from collections.abc import Callable, Iterator, Sequence @@ -1139,7 +1138,7 @@ def where(self, *conditions: Q) -> Self: type checker can reject typos and value-type mismatches at the call site. """ - return self._filter_or_exclude(False, conditions, {}) + return self.filter(*conditions) def _filter_or_exclude( self, negate: bool, args: tuple[Any, ...], kwargs: dict[str, Any] @@ -1899,9 +1898,9 @@ def get_prefetcher( ) -> tuple[Any, Any, bool, Callable[[Model], bool]]: """ For the attribute 'through_attr' on the given instance, find - an object that has a get_prefetch_queryset(). + an object that has a _get_prefetch_queryset(). Return a 4 tuple containing: - (the object with get_prefetch_queryset (or None), + (the object with _get_prefetch_queryset (or None), the descriptor object representing this relationship (or None), a boolean that is False if the attribute was not found at all, a function that takes an instance and returns a boolean that is True if @@ -1916,26 +1915,24 @@ def has_to_attr_attribute(instance: Model) -> bool: # For singly related objects, we have to avoid getting the attribute # from the object, as this will trigger the query. So we first try - # on the class, in order to get the descriptor object. Use - # getattr_static so a forward FK yields its descriptor rather than the - # RelatedFieldRef traversal proxy its __get__ returns for class access. - rel_obj_descriptor = inspect.getattr_static(instance.__class__, through_attr, None) + # on the class, in order to get the descriptor object. + rel_obj_descriptor = getattr(instance.__class__, through_attr, None) if rel_obj_descriptor is None: attr_found = hasattr(instance, through_attr) else: attr_found = True if rel_obj_descriptor: # singly related object, descriptor object has the - # get_prefetch_queryset() method. - if hasattr(rel_obj_descriptor, "get_prefetch_queryset"): + # _get_prefetch_queryset() method. + if hasattr(rel_obj_descriptor, "_get_prefetch_queryset"): prefetcher = rel_obj_descriptor - is_fetched = rel_obj_descriptor.is_cached + is_fetched = rel_obj_descriptor._is_cached else: # descriptor doesn't support prefetching, so we go ahead and get # the attribute on the instance rather than the class to # support many related managers rel_obj = getattr(instance, through_attr) - if hasattr(rel_obj, "get_prefetch_queryset"): + if hasattr(rel_obj, "_get_prefetch_queryset"): prefetcher = rel_obj if through_attr != to_attr: # Special case cached_property instances because hasattr @@ -1969,7 +1966,7 @@ def prefetch_one_level( Return the prefetched objects along with any additional prefetches that must be done due to prefetch_related lookups found from default managers. """ - # prefetcher must have a method get_prefetch_queryset() which takes a list + # prefetcher must have a method _get_prefetch_queryset() which takes a list # of instances, and returns a tuple: # (queryset of instances of self.model that are related to passed in instances, @@ -1989,7 +1986,7 @@ def prefetch_one_level( single, cache_name, is_descriptor, - ) = prefetcher.get_prefetch_queryset(instances, lookup.get_current_queryset(level)) + ) = prefetcher._get_prefetch_queryset(instances, lookup.get_current_queryset(level)) # We have to handle the possibility that the QuerySet we just got back # contains some prefetch_related lookups. We don't want to trigger the # prefetch_related functionality by evaluating the query. Rather, we need diff --git a/plain-postgres/tests/public/test_related.py b/plain-postgres/tests/public/test_related.py index c28868c91c..35c482ef7b 100644 --- a/plain-postgres/tests/public/test_related.py +++ b/plain-postgres/tests/public/test_related.py @@ -1,6 +1,3 @@ -import inspect -import pickle - import pytest from app.examples.models.delete import ( ChildCascade, @@ -669,26 +666,3 @@ def test_del_missing_foreign_key_keeps_cache_intact(self, db): with pytest.raises(AttributeError): del child.parent assert fk_field.is_cached(child) # ty: ignore[unresolved-attribute] - - -class TestForwardForeignKeyDescriptorPickling: - """The descriptor pickles by name off its model. Class access runs - `__get__`, which returns a RelatedFieldRef traversal proxy, so the - reconstruction has to be static or the descriptor comes back as a proxy.""" - - def test_pickle_round_trips_to_the_descriptor(self): - descriptor = inspect.getattr_static(ChildCascade, "parent") - - restored = pickle.loads(pickle.dumps(descriptor)) - - assert type(restored) is type(descriptor) - assert restored is descriptor # the one attached to the model - assert restored.field is ChildCascade._model_meta.get_forward_field("parent") - - def test_pickle_round_trips_a_nullable_relation(self): - descriptor = inspect.getattr_static(ChildSetNull, "parent") - - restored = pickle.loads(pickle.dumps(descriptor)) - - assert restored is descriptor - assert restored.field.name == "parent" From cb7db9a15d4bffd216163bf3f1a1186adffc954f Mon Sep 17 00:00:00 2001 From: Dave Gaeddert Date: Fri, 18 Sep 2026 23:59:30 -0500 Subject: [PATCH 20/21] Address code-review findings on the typed read surface Eight verified findings, each reproduced first. **Every relation is a traversal hop, not just foreign keys.** `WidgetTag.widget.tags` fell through to `with_lookup_prefix` and came back as an M2M field copy renamed `"widget__tags"`, so `.tags.name` resolved to that *string* (`'str' object has no attribute 'equals'`) and `.tags.equals(t.id)` silently built `Q(widget__tags=1)`. Any `RelatedField` is now a hop -- `widget__tags__name` is as valid a lookup path as `widget__author__name` -- so traversal continues through it and the relation itself gets the same "use .id.()" advice. Static typing of that hop is runtime-only for now and the tests say why: `tags` is declared `ManyToManyManager[Tag]`, and typing class access as `type[Tag]` would be false for `Widget.tags` itself. **One traversal rule instead of two.** The descriptor's `__getattr__` duplicated `RelatedFieldRef.__getattr__` and re-ran four function-local imports per attribute read. It now builds a `RelatedFieldRef` once per descriptor (`cached_property`, like `RelatedObjectDoesNotExist`, because the remote model may still be a string at construction) and delegates. The M2M fix above therefore lives in one place. **A traversal that runs before the registry resolves says so.** The descriptor dereferenced `remote_field.model._model_meta` with no guard, so a module-level traversal raised `'str' has no attribute '_model_meta'` -- which `hasattr` then swallowed, turning a timing problem into a missing attribute. Delegation routes it through `RelatedFieldRef.__init__`'s assertion, whose message now names the unresolved string and says to move the call inside a function. **`is_in` refuses a bare str/bytes.** `Iterable[str]` is satisfied by `str`, so `name.is_in("abc")` type-checked and iterated characters. Not expressible statically (`str` is a `Sequence[str]`), so `_build_q` refuses it next to the None guard and names the list spelling. **Encrypted equality raises through `get_or_create()` too, deliberately.** `get_or_create(api_key="k")` previously "worked" by creating a new row every call, because the lookup could never match existing ciphertext. It now raises -- that silent duplication is exactly what the block exists to prevent -- and the message drops the blanket "use .is_null()" for advice that fits: equality on an encrypted column can never match, `is_null()` is the only condition, and `get_or_create`/`update_or_create` callers should move the value into `defaults=`. Covered for the kwarg, `get_or_create` and `F()` paths, and documented in the README as a break. **Docs matched to the code.** The relation-condition error is an `AttributeError`, not a `TypeError`, in the README and the package-level rule (regenerated with `plain agent install`; mirrors byte-identical). Stale comments fixed: the encrypted JSON case describes `EncryptedField[dict | None]` rather than a mixin and gains the static pins the other eleven have (spelled directly, since a marker can't bind through `getattr`), and the string-condition block no longer claims a non-text field lacks the method at runtime -- it doesn't, which is the whole reason the guard is type-first. **Public tests test the public surface.** The `CONDITION_METHODS` sweeps, `with_lookup_prefix` and `_model_meta` cases move to `tests/internal/test_typed_where_internals.py` per tests-layout; the public files keep the documented spellings. --- .claude/rules/plain-postgres.md | 3 +- plain-postgres/plain/postgres/README.md | 12 ++- .../agents/.claude/rules/plain-postgres.md | 3 +- plain-postgres/plain/postgres/fields/base.py | 10 ++ .../plain/postgres/fields/encrypted.py | 27 +++--- .../postgres/fields/related_descriptors.py | 58 +++++------ .../plain/postgres/fields/related_typed.py | 21 +++- .../internal/test_typed_where_internals.py | 74 ++++++++++++++ .../tests/public/test_encrypted_fields.py | 57 +++++++++-- .../tests/public/test_typed_where.py | 10 +- .../tests/public/test_typed_where_fk.py | 97 ++++++++++--------- 11 files changed, 264 insertions(+), 108 deletions(-) create mode 100644 plain-postgres/tests/internal/test_typed_where_internals.py diff --git a/.claude/rules/plain-postgres.md b/.claude/rules/plain-postgres.md index a46a11dfc0..fc68820c2d 100644 --- a/.claude/rules/plain-postgres.md +++ b/.claude/rules/plain-postgres.md @@ -99,7 +99,8 @@ Run `uv run plain docs postgres` for full workflow details. Use `Model.query` to build querysets (e.g., `User.query.filter(is_active=True)`). - `where()` takes typed conditions built off fields (`User.query.where(User.role.equals("admin"))`) instead of `filter()`'s string kwargs, so a typo or wrong value type is caught at the call site. -- **Conditions on a relation go through its key**: `Post.query.where(Post.author.id.equals(author.id))`, `.id.is_in([...])`, `.id.is_null()` — the typed spelling of `filter(author=author)`, same SQL. `Post.author.equals(author)` raises `TypeError`: `Post.author` is `type[Author]` to the checker (which is what makes `Post.author.email.equals(...)` work), so it offers the related model's fields, not conditions. +- **Conditions on a relation go through its key**: `Post.query.where(Post.author.id.equals(author.id))`, `.id.is_in([...])`, `.id.is_null()` — the typed spelling of `filter(author=author)`, same SQL. `Post.author.equals(author)` raises `AttributeError`: `Post.author` is `type[Author]` to the checker (which is what makes `Post.author.email.equals(...)` work), so it offers the related model's fields, not conditions. Many-to-many relations traverse the same way (`Widget.tags.name.equals(...)`). +- Encrypted fields can't be looked up at all: `get_or_create(secret=...)` raises — put the value in `defaults=`. - Use `select_related()` for FK access in loops, `prefetch_related()` for reverse/M2N - A foreign key returns a partial related object: `obj.author` and `obj.author.id` are query-free; other fields load on first access. There is no `obj.author_id` — use `obj.author.id` - Use `.annotate(Count(...))` instead of calling `.count()` per row diff --git a/plain-postgres/plain/postgres/README.md b/plain-postgres/plain/postgres/README.md index a3217c3666..9b2c1fad3a 100644 --- a/plain-postgres/plain/postgres/README.md +++ b/plain-postgres/plain/postgres/README.md @@ -245,7 +245,9 @@ Post.query.where(Post.author.id.is_in([a.id for a in authors])) Post.query.where(Post.author.id.is_null()) # nullable relation ``` -`Post.author.equals(author)` raises `TypeError` naming this spelling. It isn't an oversight: to the type checker `Post.author` is `type[Author]`, which is what makes `Post.author.email.equals(...)` type-check, and a condition method there would be a runtime method the checker rejects. +`Post.author.equals(author)` raises `AttributeError` naming this spelling (an `AttributeError`, so `hasattr` and `getattr(..., default)` keep behaving). It isn't an oversight: to the type checker `Post.author` is `type[Author]`, which is what makes `Post.author.email.equals(...)` type-check, and a condition method there would be a runtime method the checker rejects. + +Every relation reached _through_ a traversal is a hop, many-to-many included — `WidgetTag.widget.tags.name.equals("metal")` builds `Q(widget__tags__name="metal")` — and the same rule applies to the relation itself: `WidgetTag.widget.tags.equals(tag)` points you at `.tags.id.equals(tag.id)`. (Traversal starts from a forward foreign key; class-level many-to-many access like `Widget.tags` is not a traversal entry point.) A traversed field _is_ the related field, carrying the relation path as its name — so it offers exactly the conditions that field offers, including an encrypted field's refusals. @@ -1074,6 +1076,14 @@ Values are encrypted using Fernet (AES-128-CBC + HMAC-SHA256) with a key derived **Limitations:** - **No lookups** — encrypted values are non-deterministic (same plaintext produces different ciphertext each time), so filtering on encrypted fields doesn't work. Only `isnull` lookups are supported. Comparing against a value raises `TypeError` rather than silently matching nothing — both `filter(api_key="x")` and the typed [condition methods](#typed-conditions-with-where) (`equals`, `contains`, …), which are also rejected at the call site when the field is annotated `EncryptedField[T]`. `filter(api_key=None)` still rewrites to `IS NULL`. +- **`get_or_create()` must not look up an encrypted field.** `get_or_create(api_key="k")` raises, and the error says to move the value into `defaults=`. This is a deliberate break: it previously "worked" by creating a new row on every call, because the lookup could never match existing ciphertext. An encrypted value can be written, just not looked up: + + ```python + Integration.query.get_or_create(name="acme", defaults={"api_key": "k"}) + ``` + + The same applies to `update_or_create()`, and to an expression right-hand side like `filter(api_key=F("name"))` — the column is still ciphertext. + - **No indexes or constraints** — encrypted fields cannot be used in indexes or unique constraints. Preflight checks will catch this. - **Only `default=""`** — on `EncryptedTextField` (paired with `required=False`), the empty string is stored as plaintext `''`, so it's the one value expressible as a column `DEFAULT` (declare it to add the field to a populated table). Any other default would need ciphertext, which is non-deterministic. `EncryptedJSONField` has no persistent default at all — even `{}` serializes to text that would need ciphertext — so pair `allow_null=True` with `default=None`, which stores nothing and just marks the field optional in the constructor. diff --git a/plain-postgres/plain/postgres/agents/.claude/rules/plain-postgres.md b/plain-postgres/plain/postgres/agents/.claude/rules/plain-postgres.md index a46a11dfc0..fc68820c2d 100644 --- a/plain-postgres/plain/postgres/agents/.claude/rules/plain-postgres.md +++ b/plain-postgres/plain/postgres/agents/.claude/rules/plain-postgres.md @@ -99,7 +99,8 @@ Run `uv run plain docs postgres` for full workflow details. Use `Model.query` to build querysets (e.g., `User.query.filter(is_active=True)`). - `where()` takes typed conditions built off fields (`User.query.where(User.role.equals("admin"))`) instead of `filter()`'s string kwargs, so a typo or wrong value type is caught at the call site. -- **Conditions on a relation go through its key**: `Post.query.where(Post.author.id.equals(author.id))`, `.id.is_in([...])`, `.id.is_null()` — the typed spelling of `filter(author=author)`, same SQL. `Post.author.equals(author)` raises `TypeError`: `Post.author` is `type[Author]` to the checker (which is what makes `Post.author.email.equals(...)` work), so it offers the related model's fields, not conditions. +- **Conditions on a relation go through its key**: `Post.query.where(Post.author.id.equals(author.id))`, `.id.is_in([...])`, `.id.is_null()` — the typed spelling of `filter(author=author)`, same SQL. `Post.author.equals(author)` raises `AttributeError`: `Post.author` is `type[Author]` to the checker (which is what makes `Post.author.email.equals(...)` work), so it offers the related model's fields, not conditions. Many-to-many relations traverse the same way (`Widget.tags.name.equals(...)`). +- Encrypted fields can't be looked up at all: `get_or_create(secret=...)` raises — put the value in `defaults=`. - Use `select_related()` for FK access in loops, `prefetch_related()` for reverse/M2N - A foreign key returns a partial related object: `obj.author` and `obj.author.id` are query-free; other fields load on first access. There is no `obj.author_id` — use `obj.author.id` - Use `.annotate(Count(...))` instead of calling `.count()` per row diff --git a/plain-postgres/plain/postgres/fields/base.py b/plain-postgres/plain/postgres/fields/base.py index 8fc9683906..758b9dbc5c 100644 --- a/plain-postgres/plain/postgres/fields/base.py +++ b/plain-postgres/plain/postgres/fields/base.py @@ -235,6 +235,16 @@ def _build_q(self, method: str, suffix: str, value: Any) -> Q: f"meaning for None -- a SQL comparison against NULL is never " f"true. Use .is_null() instead." ) + if suffix == "in" and isinstance(value, str | bytes): + # `Iterable[T]` is satisfied by `str` when T is `str`, and `str` is + # a `Sequence[str]`, so there is no way to exclude it statically. + # Left alone it iterates characters and silently matches the wrong + # rows. + raise TypeError( + f"{type(self).__name__} {self.name!r}: .is_in() takes a " + f"collection of values, not a single {type(value).__name__}. " + f"Pass a list -- .is_in([{value!r}])." + ) if suffix and not self.get_lookup(suffix): # The type checker rejects most of these already (a `Field[int]` # has no `.startswith`); this catches what it can't see. diff --git a/plain-postgres/plain/postgres/fields/encrypted.py b/plain-postgres/plain/postgres/fields/encrypted.py index d1db765823..902dcf4482 100644 --- a/plain-postgres/plain/postgres/fields/encrypted.py +++ b/plain-postgres/plain/postgres/fields/encrypted.py @@ -110,11 +110,12 @@ def _decrypt(value: str) -> str: ) -# Shared tail explaining why encrypted fields reject value comparisons — used -# by every refusal, which all route through -# `EncryptedField._lookup_unsupported_message`. +# Shared tail explaining why encrypted fields reject value comparisons. Kept +# free of advice, because the right advice differs by caller -- see the two +# refusal sites below. _NON_DETERMINISTIC_EXPLANATION = ( - "ciphertext is non-deterministic. Use .is_null() instead." + "encrypting the same plaintext twice produces different ciphertext, so an " + "equality comparison on the column can never match" ) @@ -131,13 +132,14 @@ def __init__(self, lhs: Any, rhs: Any) -> None: if rhs is not None: # lhs.output_field is the encrypted field itself (the lookups.py # idiom). Its own sentence, not _lookup_unsupported_message's: - # `filter()` *is* supported here, just not against a value, and - # saying "does not support .filter()" would be wrong. Both share - # the explanation tail below. + # `filter()` *is* supported here, just not against a value. + name = lhs.output_field.name raise TypeError( - f"Encrypted field {lhs.output_field.name!r} cannot be filtered " - f"by equality against a non-None value — " - f"{_NON_DETERMINISTIC_EXPLANATION}" + f"Encrypted field {name!r} cannot be matched against a value: " + f"{_NON_DETERMINISTIC_EXPLANATION}. `is_null()` (or " + f"{name}=None) is the only condition it supports. If this came " + f"from get_or_create()/update_or_create(), move {name!r} into " + f"defaults= -- it can be written, just not looked up." ) super().__init__(lhs, rhs) @@ -242,8 +244,9 @@ def _lookup_unsupported_message(self, method: str) -> str: "methods can produce a meaningful error message." ) return ( - f"Encrypted field {self.name!r} does not support .{method}() — " - f"{_NON_DETERMINISTIC_EXPLANATION}" + f"Encrypted field {self.name!r} does not support .{method}(): " + f"{_NON_DETERMINISTIC_EXPLANATION}. .is_null() is the only " + f"condition it supports." ) def preflight(self, **kwargs: Any) -> list[PreflightResult]: diff --git a/plain-postgres/plain/postgres/fields/related_descriptors.py b/plain-postgres/plain/postgres/fields/related_descriptors.py index 4b8989fa20..be0860eb77 100644 --- a/plain-postgres/plain/postgres/fields/related_descriptors.py +++ b/plain-postgres/plain/postgres/fields/related_descriptors.py @@ -39,7 +39,6 @@ class Child(Model): from functools import cached_property from typing import Any -from plain.postgres.constants import LOOKUP_SEP from plain.postgres.query import QuerySet from plain.utils.functional import LazyObject @@ -152,6 +151,27 @@ def __get__(self, instance: Any | None, cls: type | None = None) -> Any: ) return rel_obj + @cached_property + def _typed_ref(self) -> Any: + """The traversal entry point for this relation. + + A `cached_property` for the same reason `RelatedObjectDoesNotExist` is + one: at construction time `remote_field.model` may still be a string, + and it is replaced with the resolved class when the model registers. + Built once per descriptor, so class-level traversal allocates nothing + and re-imports nothing. + + The import is local because `related_typed` reaches `fields.related`, + which imports this module at load time. + """ + from plain.postgres.fields.related_typed import RelatedFieldRef + + return RelatedFieldRef( + model=self._field.remote_field.model, + prefix=self._field.name, + target_name=self._field.target_field.name, + ) + def __getattr__(self, name: str) -> Any: """Walk class-level attribute access into the related model, so typed where() can build joined lookups: @@ -163,43 +183,13 @@ def __getattr__(self, name: str) -> Any: never be (``Meta`` skips ``_``-prefixed attributes when it collects them), so a related field can't be shadowed by descriptor internals. - A leaf field comes back as a copy of itself carrying the relation - prefix; a further foreign key hands off to ``RelatedFieldRef``, which - accumulates the path for hops beyond the first. + Delegated to `RelatedFieldRef` so the first hop follows exactly the + same rule as every later one. """ if name.startswith("_"): # Internals, and anything a field could never be named. raise AttributeError(name) - - from plain.postgres.exceptions import FieldDoesNotExist - from plain.postgres.fields.base import CONDITION_METHODS - from plain.postgres.fields.related import ForeignKeyField - from plain.postgres.fields.related_typed import RelatedFieldRef - - related_model = self._field.remote_field.model - prefix = self._field.name - try: - field = related_model._model_meta.get_forward_field(name) - except FieldDoesNotExist: - if name in CONDITION_METHODS: - raise AttributeError( - f"{prefix}.{name}() is not available: {prefix!r} is a " - f"relation, not a field. Build the condition on the key it " - f"points at instead -- " - f"{prefix}.{self._field.target_field.name}.{name}(...), " - f"which compiles to the same SQL." - ) from None - raise AttributeError( - f"{prefix}.{name} is not a traversable field or relation" - ) from None - - if isinstance(field, ForeignKeyField): - return RelatedFieldRef( - model=field.remote_field.model, - prefix=f"{prefix}{LOOKUP_SEP}{name}", - target_name=field.target_field.name, - ) - return field.with_lookup_prefix(prefix) + return getattr(self._typed_ref, name) def __set__(self, instance: Any, value: Any) -> None: """ diff --git a/plain-postgres/plain/postgres/fields/related_typed.py b/plain-postgres/plain/postgres/fields/related_typed.py index 14c30cc6e5..1b4370e58f 100644 --- a/plain-postgres/plain/postgres/fields/related_typed.py +++ b/plain-postgres/plain/postgres/fields/related_typed.py @@ -18,7 +18,7 @@ from plain.postgres.constants import LOOKUP_SEP from plain.postgres.exceptions import FieldDoesNotExist from plain.postgres.fields.base import CONDITION_METHODS -from plain.postgres.fields.related import ForeignKeyField +from plain.postgres.fields.related import RelatedField if TYPE_CHECKING: from plain.postgres.base import Model @@ -30,7 +30,9 @@ class RelatedFieldRef: Chained traversal (`Order.user.profile.city`) builds nested `RelatedFieldRef` instances until a concrete field is reached, which comes - back as a prefixed copy of that field. + back as a prefixed copy of that field. Every relation is a hop -- many-to- + many included, since `widget__tags__name` is as valid a lookup path as + `widget__author__name`. Names resolve through the related model's metadata (`get_forward_field`), not attribute lookup, so a related field keeps resolving to the field. @@ -48,8 +50,12 @@ class RelatedFieldRef: def __init__(self, model: type[Model], prefix: str, target_name: str) -> None: assert not isinstance(model, str), ( - "RelatedFieldRef requires a resolved model class; the FK's " - "remote_field.model is replaced with the class at registration." + f"Cannot traverse {prefix!r}: its target model is still the string " + f"{model!r}. Relation targets are replaced with the resolved class " + f"when the model is registered, so a traversal that runs at import " + f"time -- at module level, or in a default argument -- can land " + f"here before the registry is populated. Move it inside the " + f"function or method that needs it." ) self._model = model self._prefix = prefix @@ -85,7 +91,12 @@ def __getattr__(self, name: str) -> Any: f"{self._prefix}.{name} is not a traversable field or relation" ) from None - if isinstance(field, ForeignKeyField): + if isinstance(field, RelatedField): + # Any relation is another hop, foreign key or many-to-many alike -- + # `widget__tags__name` is as valid a lookup path as + # `widget__author__name`. Handing back the relation field itself + # would rename it to "widget__tags" and then let `.name` resolve to + # that string. return RelatedFieldRef( model=field.remote_field.model, prefix=f"{self._prefix}{LOOKUP_SEP}{name}", diff --git a/plain-postgres/tests/internal/test_typed_where_internals.py b/plain-postgres/tests/internal/test_typed_where_internals.py new file mode 100644 index 0000000000..336dd13d7f --- /dev/null +++ b/plain-postgres/tests/internal/test_typed_where_internals.py @@ -0,0 +1,74 @@ +"""Internals of the typed where() surface. + +These reach past the documented API -- `CONDITION_METHODS`, `_model_meta`, +`Field.with_lookup_prefix` -- to pin the *mechanism* rather than the contract. +The contract lives in `tests/public/test_typed_where.py` and +`test_typed_where_fk.py`; if these fail and those don't, something shifted +under the hood and you get to decide whether it should have. +""" + +from __future__ import annotations + +import pytest +from app.examples.models.delete import ChildCascade, DeleteParent +from app.examples.models.encrypted import SecretStore +from plain.postgres.fields.base import CONDITION_METHODS + + +def test_traversal_hands_back_the_field_itself(): + """The traversed object is the related model's own field, renamed. That is + what makes its surface identical to direct access by construction, rather + than by a delegation list someone has to keep in sync.""" + direct = DeleteParent._model_meta.get_forward_field("name") + traversed = ChildCascade.parent.name + + assert type(traversed) is type(direct) + assert traversed.name == "parent__name" + assert direct.name == "name" # the original is untouched + + +@pytest.mark.parametrize("method", CONDITION_METHODS) +def test_every_condition_name_gets_the_relation_advice(method): + """Sweep the whole condition surface, so a method added later can't quietly + fall through to the generic "not a traversable field" message.""" + with pytest.raises(AttributeError) as excinfo: + getattr(ChildCascade.parent, method) + + message = str(excinfo.value) + assert "is a relation, not a field" in message + assert f"parent.id.{method}(...)" in message + + +class TestEncryptedFieldTraversalBlocked: + """An encrypted field's refusals travel with it, because traversal hands + back the field itself. The message names the full path, since the prefixed + copy carries it as its name.""" + + @pytest.fixture + def traversed(self): + # No model in the examples app has an FK to SecretStore, so prefix the + # field directly. This is exactly what RelatedFieldRef hands back. + return SecretStore._model_meta.get_forward_field("api_key").with_lookup_prefix( + "store" + ) + + @pytest.mark.parametrize("method", [m for m in CONDITION_METHODS if m != "is_null"]) + def test_traversed_condition_raises(self, traversed, method): + with pytest.raises( + TypeError, match=rf"store__api_key.*does not support \.{method}\(" + ): + getattr(traversed, method)("x") + + def test_traversed_is_null_still_works(self, traversed): + assert traversed.is_null().children == [("store__api_key__isnull", True)] + + +def test_traversal_before_the_target_resolves_says_so(): + """A relation's target is a string until the model registers. A traversal + that runs at import time can land here first, and the old code dereferenced + `str._model_meta` -- an AttributeError that `hasattr` then swallowed, so + the symptom was a missing attribute rather than a timing problem.""" + from plain.postgres.fields.related_typed import RelatedFieldRef + + with pytest.raises(AssertionError, match=r"still the string 'Tag'"): + RelatedFieldRef(model="Tag", prefix="tags", target_name="id") # ty: ignore[invalid-argument-type] diff --git a/plain-postgres/tests/public/test_encrypted_fields.py b/plain-postgres/tests/public/test_encrypted_fields.py index dfb9341cb7..30583908a0 100644 --- a/plain-postgres/tests/public/test_encrypted_fields.py +++ b/plain-postgres/tests/public/test_encrypted_fields.py @@ -4,6 +4,7 @@ import pytest from app.examples.models.encrypted import SecretStore +from plain.postgres import F from plain.postgres.exceptions import FieldError from plain.postgres.fields.encrypted import ( _ENCRYPTED_PREFIX, @@ -226,6 +227,21 @@ def test_every_blocked_method_is_rejected_statically(self): with pytest.raises(TypeError): SecretStore.api_key.endswith("x") # ty: ignore[invalid-argument-type] + def test_json_field_is_rejected_statically(self): + """`config` is annotated `EncryptedField[dict | None]`, so class access + types as the field and the same static block applies. Spelled directly + rather than through getattr, because a marker can't bind to a dynamic + call.""" + with pytest.raises(TypeError): + SecretStore.config.equals({"a": 1}) # ty: ignore[invalid-argument-type] + with pytest.raises(TypeError): + SecretStore.config.not_equal({"a": 1}) # ty: ignore[invalid-argument-type] + with pytest.raises(TypeError): + SecretStore.config.is_in([{"a": 1}]) # ty: ignore[invalid-argument-type] + + def test_json_field_is_null_survives_the_block(self): + assert_type(SecretStore.config.is_null(), Q) + def test_is_null_survives_the_block_statically(self): """The one condition that stays open must keep its real signature - no ignore marker here, so a `Never` creeping onto is_null breaks the @@ -237,9 +253,9 @@ def test_is_null_survives_the_block_statically(self): "method", ["equals", "not_equal", "gt", "gte", "lt", "lte", "is_in"] ) def test_json_field_comparison_raises(self, method): - """EncryptedJSONField carries the mixin's block too. Only the runtime - side is assertable here - the model annotates `config` as `dict | None`, - so class access doesn't type as the field.""" + """EncryptedJSONField carries the same block. This case covers the + runtime half; `test_json_field_is_rejected_statically` below covers + the static half, which needs direct call sites rather than getattr.""" with pytest.raises(TypeError, match=rf"does not support \.{method}\("): getattr(SecretStore.config, method)("x") @@ -264,15 +280,13 @@ class TestKwargFilterBlocked: def test_filter_non_none_raises(self, db): with pytest.raises( - TypeError, - match=r"api_key.*cannot be filtered by equality against a non-None value", + TypeError, match=r"api_key.*cannot be matched against a value" ): SecretStore.query.filter(api_key="sk-test").count() def test_exclude_non_none_raises(self, db): with pytest.raises( - TypeError, - match=r"api_key.*cannot be filtered by equality against a non-None value", + TypeError, match=r"api_key.*cannot be matched against a value" ): SecretStore.query.exclude(api_key="sk-test").count() @@ -281,6 +295,35 @@ def test_filter_none_still_rewrites_to_isnull(self, db): SecretStore.query.create(name="test", api_key="sk-test", config=None) assert SecretStore.query.filter(config=None).count() == 1 + def test_get_or_create_on_an_encrypted_lookup_raises(self, db): + """A deliberate break. This used to "work": ciphertext never matched, + so every call created another row. Raising is the point of the block, + and the message has to say where the value belongs instead.""" + with pytest.raises(TypeError, match=r"move 'api_key' into defaults="): + SecretStore.query.get_or_create(name="test", api_key="sk-test", config=None) + + def test_get_or_create_with_the_encrypted_value_in_defaults_works(self, db): + """The spelling the message points at.""" + obj, created = SecretStore.query.get_or_create( + name="test", defaults={"api_key": "sk-test", "config": None} + ) + assert created + assert obj.api_key == "sk-test" + + again, created_again = SecretStore.query.get_or_create( + name="test", defaults={"api_key": "other", "config": None} + ) + assert not created_again + assert again.id == obj.id + + def test_filter_against_an_expression_raises(self, db): + """An F() right-hand side is a value comparison too — the column is + still ciphertext, so it can never match.""" + with pytest.raises( + TypeError, match=r"api_key.*cannot be matched against a value" + ): + SecretStore.query.filter(api_key=F("name")).count() + class TestKeyRotation: def test_decrypt_with_fallback_key(self): diff --git a/plain-postgres/tests/public/test_typed_where.py b/plain-postgres/tests/public/test_typed_where.py index a90a4d9498..224dca931e 100644 --- a/plain-postgres/tests/public/test_typed_where.py +++ b/plain-postgres/tests/public/test_typed_where.py @@ -79,9 +79,13 @@ def _typed_check_string_conditions_are_string_only() -> None: # annotation that restricts them to string-valued fields, so they # survive the `Field[T]` annotation models carry without becoming # available on every field. The ignore marker is load-bearing — if the - # restriction were dropped, ty would report it as unused. These stay - # type-check-only because a non-text field has no such method at - # runtime (AttributeError), which is what traversal reflects. + # restriction were dropped, ty would report it as unused. + # + # These stay type-check-only because the restriction is type-only: + # `Contains` and friends are registered on `Field` itself, so + # `IntegerField.contains("9")` builds a perfectly valid lookup at + # runtime. The checker is the guard, which is why running these would + # prove nothing. DefaultsExample.name.startswith("a") DefaultsExample.note.contains("a") DefaultsExample.priority.startswith("a") # ty: ignore[invalid-argument-type] diff --git a/plain-postgres/tests/public/test_typed_where_fk.py b/plain-postgres/tests/public/test_typed_where_fk.py index 5036418547..d2281b0d35 100644 --- a/plain-postgres/tests/public/test_typed_where_fk.py +++ b/plain-postgres/tests/public/test_typed_where_fk.py @@ -16,10 +16,8 @@ Grandparent, MidParent, ) -from app.examples.models.encrypted import SecretStore from app.examples.models.relationships import Tag, Widget, WidgetTag from app.examples.models.shadowing import ShadowSource, ShadowTarget -from plain.postgres.fields.base import CONDITION_METHODS from plain.postgres.query_utils import Q @@ -154,44 +152,6 @@ def test_two_hop_chain_combines_with_or(db): # --------------------------------------------------------------------------- -class TestEncryptedFieldTraversalBlocked: - """Traversal hands back the field itself, so an encrypted field's blocks - arrive with it — the guard is per-field, not per-call-site. The error even - names the full path, because the prefixed copy carries it as its name.""" - - @pytest.fixture - def traversed(self): - # No model in the examples app has an FK to SecretStore, so prefix the - # field directly. This is exactly what RelatedFieldRef hands back. - return SecretStore._model_meta.get_forward_field("api_key").with_lookup_prefix( - "store" - ) - - @pytest.mark.parametrize( - "method", - [m for m in CONDITION_METHODS if m != "is_null"], - ) - def test_traversed_condition_raises(self, traversed, method): - with pytest.raises( - TypeError, match=rf"store__api_key.*does not support \.{method}\(" - ): - getattr(traversed, method)("x") - - def test_traversed_is_null_still_works(self, traversed): - assert traversed.is_null().children == [("store__api_key__isnull", True)] - - -def test_traversal_hands_back_the_field_itself(): - """The traversed object is the related model's own field, renamed — which - is what makes its surface identical to direct access by construction.""" - direct = DeleteParent._model_meta.get_forward_field("name") - traversed = ChildCascade.parent.name - - assert type(traversed) is type(direct) - assert traversed.name == "parent__name" - assert direct.name == "name" # the original is untouched - - # --------------------------------------------------------------------------- # Descriptor attribute shadowing: these four names were once public attributes # on ForwardForeignKeyDescriptor, so a related field named after one of them @@ -308,11 +268,12 @@ def test_where_filters_by_null_relation_key(db): assert [r.id for r in non_nulls] == [attached.id] -@pytest.mark.parametrize("method", CONDITION_METHODS) +@pytest.mark.parametrize("method", ["equals", "is_in", "is_null", "gte", "contains"]) def test_condition_on_the_relation_itself_raises_helpful_error(method): """An AttributeError, so `hasattr`/`getattr(..., default)` keep working -- but one that names the spelling that does work, or the constraint just - looks like a missing feature.""" + looks like a missing feature. (The sweep across every condition name is in + tests/internal/test_typed_where_internals.py.)""" with pytest.raises(AttributeError) as excinfo: getattr(ChildCascade.parent, method) @@ -327,8 +288,8 @@ def test_condition_on_the_relation_keeps_the_attribute_protocol(): def test_unknown_relation_attribute_still_raises_attribute_error(): - """Only the condition names get the TypeError; a genuine typo stays an - AttributeError so `hasattr` and friends behave.""" + """A genuine typo gets the plain "not a traversable field" message rather + than the condition-method advice.""" with pytest.raises(AttributeError, match="parent.nope is not a traversable"): getattr(ChildCascade.parent, "nope") @@ -337,3 +298,51 @@ def test_related_field_named_like_a_condition_still_traverses(): """The field lookup runs first, so a related model that really does have a column named after a condition method still resolves to the column.""" assert ShadowSource.ref.field.equals("hit").children == [("ref__field", "hit")] + + +# --------------------------------------------------------------------------- +# Many-to-many relations traverse like any other. Reached *through* a relation, +# `widget__tags__name` is as valid a lookup path as `widget__author__name`, so +# an M2M is a hop, not a leaf -- handing the M2M field back renamed would make +# `.name` resolve to the string "widget__tags". +# +# (Direct class access, `Widget.tags`, is a ForwardManyToManyDescriptor and has +# no traversal wiring; this branch covers forward-FK traversal only.) +# +# This hop is runtime-only for now: at the type level `tags` is declared +# `ManyToManyManager[Tag]`, which exposes the manager API, not the target +# model's fields. Typing it would mean claiming class access to an M2M yields +# `type[Tag]` -- true after a traversal hop, false for `Widget.tags` itself -- +# so the ignores below are the honest marker rather than a papered-over bug. +# --------------------------------------------------------------------------- + + +def test_m2m_traversal_through_a_foreign_key(): + q = WidgetTag.widget.tags.name.equals( # ty: ignore[unresolved-attribute] + "metal" + ) + assert q.children == [("widget__tags__name", "metal")] + + +def test_condition_on_a_traversed_m2m_gets_the_same_advice(): + with pytest.raises(AttributeError) as excinfo: + getattr(WidgetTag.widget.tags, "equals") + + message = str(excinfo.value) + assert "is a relation, not a field" in message + assert "widget__tags.id.equals(...)" in message + + +def test_where_filters_through_a_foreign_key_then_an_m2m(db): + metal = Tag.query.create(name="metal") + plastic = Tag.query.create(name="plastic") + cog = Widget.query.create(name="cog", size="small") + knob = Widget.query.create(name="knob", size="small") + WidgetTag.query.create(widget=cog, tag=metal) + WidgetTag.query.create(widget=knob, tag=plastic) + + condition = WidgetTag.widget.tags.name.equals( # ty: ignore[unresolved-attribute] + "metal" + ) + rows = list(WidgetTag.query.where(condition)) + assert [r.widget.id for r in rows] == [cog.id] From 25f06e8ef3903b0e03ea203f1a88af8b31ad4613 Mon Sep 17 00:00:00 2001 From: Dave Gaeddert Date: Sat, 19 Sep 2026 01:23:13 -0500 Subject: [PATCH 21/21] Address xhigh review: encrypted empty string, traversal edges, detached refs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten findings; each reproduced before fixing. **The empty string is matchable, and the two query paths now agree.** `_encrypt("")` returns `""` -- the empty string is stored as plaintext, which is exactly what makes `default=""` expressible as a column DEFAULT and what the README's own `required=False, default=""` idiom relies on. Blocking it regressed `exclude(token="")` with a message that was false for that value. Both paths now ask the field which values it stores deterministically: `matches_deterministically` returns None for every encrypted field, and text fields add `""`. That closed a second gap -- `field.equals(None)` raised while `filter(field=None)` worked, and the error even advertised `=None`. The static side had to move with it, so `equals`/`not_equal` are narrowed (overloaded on the `self` type, since models annotate `EncryptedField[T]` and that annotation is all the checker sees) instead of blocked outright. The other nine stay `Never`. **Traversal edges.** An unresolved string FK target made `ForeignObjectRel.model` raise TypeError straight out of `__getattr__`, so `hasattr` *raised* instead of returning False; it now raises `UnresolvedRelationError` (an AttributeError subclass) carrying the "move this inside a function" message. The ref is cached by hand rather than by `cached_property`, because an AttributeError raised inside a descriptor's `__get__` sends Python back to `__getattr__` and the message was being replaced by a bare "no attribute '_typed_ref'". The "use the key" advice spelled the path with lookup separators past the first hop (`widget__tags.id`); it is built from the attribute spelling now (`widget.tags.id`). Reverse relations reported "not a traversable field or relation", which is false -- `filter(author__posts__title=...)` works -- so they get their own message naming the `filter()` path and why the typed API can't express them. **Traversed fields are properly detached.** The copy kept `model`, `column` and `cached_col`, so `str()` claimed `examples.DeleteParent.parent__name` -- a column that doesn't exist. It now drops the attachment state and says what it is: `parent__name (lookup reference)`. Pickling round-trips as a plain field, which the existing `__reduce__` already handled once `model` was gone. **`Field[Any]` silently loses the typed surface** -- `Any` satisfies the model-valued `__get__` overload, so class access types as `type[Any]`. That is a checker limitation, not something to outsmart; plain-cache's `value` is annotated `Field[object]` instead (the column holds arbitrary JSON, so `dict[str, Any]` would be false), verified by probe, and the rule now says not to reach for `Field[Any]`. **Docs.** The rule and README claimed `Widget.tags.name.equals(...)` works from a class-level M2M; it doesn't and couldn't be typed. They now say traversal starts from a forward FK, an M2M is traversable only as a later hop, and to use `filter(tags__name=...)` otherwise. `RelatedObjectDoesNotExist` is documented with its typed spelling (`except Related.DoesNotExist`), since class access now types as the related model. And "Text fields add contains…" became every string-valued field, which is what shipped. --- .claude/rules/plain-postgres.md | 9 +- plain-cache/plain/cache/models.py | 10 +- plain-postgres/plain/postgres/README.md | 6 +- .../agents/.claude/rules/plain-postgres.md | 9 +- plain-postgres/plain/postgres/fields/base.py | 20 +++- .../plain/postgres/fields/encrypted.py | 104 +++++++++++++----- .../postgres/fields/related_descriptors.py | 35 ++++-- .../plain/postgres/fields/related_typed.py | 80 +++++++++++--- .../internal/test_typed_where_internals.py | 10 +- .../tests/public/test_encrypted_fields.py | 76 ++++++++++--- .../tests/public/test_typed_where_fk.py | 15 ++- 11 files changed, 299 insertions(+), 75 deletions(-) diff --git a/.claude/rules/plain-postgres.md b/.claude/rules/plain-postgres.md index fc68820c2d..576c7b02b6 100644 --- a/.claude/rules/plain-postgres.md +++ b/.claude/rules/plain-postgres.md @@ -56,7 +56,11 @@ class Article(postgres.Model): A plain `Field[T]` hides them and the comparison only fails at runtime. - **JSON**: `JSONField`/`EncryptedJSONField` return `Any` from the stub (the runtime class isn't generic over its value shape), so the annotation is what - preserves typing: `Field[dict]` / `EncryptedField[dict[str, Any]]`. + preserves typing: `Field[dict]` / `EncryptedField[dict[str, Any]]`. Never + annotate a field `Field[Any]` — `Any` satisfies the model-valued `__get__` + overload, so class access types as `type[Any]` and the whole condition + surface disappears silently. Use the concrete shape, or `Field[object]` when + the column really does hold arbitrary JSON. - **Custom querysets**: declare `query: ClassVar[MyQuerySet] = MyQuerySet()` (`ClassVar` so it isn't treated as a field). Default-queryset models declare nothing — `Model.query` is typed automatically. @@ -99,9 +103,10 @@ Run `uv run plain docs postgres` for full workflow details. Use `Model.query` to build querysets (e.g., `User.query.filter(is_active=True)`). - `where()` takes typed conditions built off fields (`User.query.where(User.role.equals("admin"))`) instead of `filter()`'s string kwargs, so a typo or wrong value type is caught at the call site. -- **Conditions on a relation go through its key**: `Post.query.where(Post.author.id.equals(author.id))`, `.id.is_in([...])`, `.id.is_null()` — the typed spelling of `filter(author=author)`, same SQL. `Post.author.equals(author)` raises `AttributeError`: `Post.author` is `type[Author]` to the checker (which is what makes `Post.author.email.equals(...)` work), so it offers the related model's fields, not conditions. Many-to-many relations traverse the same way (`Widget.tags.name.equals(...)`). +- **Conditions on a relation go through its key**: `Post.query.where(Post.author.id.equals(author.id))`, `.id.is_in([...])`, `.id.is_null()` — the typed spelling of `filter(author=author)`, same SQL. `Post.author.equals(author)` raises `AttributeError`: `Post.author` is `type[Author]` to the checker (which is what makes `Post.author.email.equals(...)` work), so it offers the related model's fields, not conditions. Traversal only _starts_ from a forward FK; a many-to-many is traversable as a later hop (`WidgetTag.widget.tags.name`), but `Widget.tags` and reverse accessors are not entry points — use `filter(tags__name=...)` there. - Encrypted fields can't be looked up at all: `get_or_create(secret=...)` raises — put the value in `defaults=`. - Use `select_related()` for FK access in loops, `prefetch_related()` for reverse/M2N +- A foreign key with no value raises `RelatedObjectDoesNotExist`; catch it as `Related.DoesNotExist` (it subclasses that and `AttributeError`) — `Model.fk.RelatedObjectDoesNotExist` is a type error now that class access types as the related model. - A foreign key returns a partial related object: `obj.author` and `obj.author.id` are query-free; other fields load on first access. There is no `obj.author_id` — use `obj.author.id` - Use `.annotate(Count(...))` instead of calling `.count()` per row - Fetch all data in the view — templates should never trigger queries diff --git a/plain-cache/plain/cache/models.py b/plain-cache/plain/cache/models.py index 0767f42d2d..c02ae0cbd3 100644 --- a/plain-cache/plain/cache/models.py +++ b/plain-cache/plain/cache/models.py @@ -1,7 +1,7 @@ from __future__ import annotations from datetime import datetime -from typing import Any, ClassVar, Self +from typing import ClassVar, Self from plain.postgres import Field, types from plain.runtime import settings @@ -38,7 +38,13 @@ def forever(self) -> Self: @postgres.register_model class CachedItem(postgres.Model): key: Field[str] = types.TextField(max_length=255) - value: Field[Any] = types.JSONField(required=False, allow_null=True, default=None) + # `object`, not `Any`: the cache stores any JSON-serializable value, and + # `Field[Any]` would make `Any` satisfy the model-valued `__get__` overload, + # so `CachedItem.value` would type as `type[Any]` and lose the field surface + # entirely. + value: Field[object] = types.JSONField( + required=False, allow_null=True, default=None + ) expires_at: Field[datetime | None] = types.DateTimeField( required=False, allow_null=True, default=None ) diff --git a/plain-postgres/plain/postgres/README.md b/plain-postgres/plain/postgres/README.md index 9b2c1fad3a..a15aa7f519 100644 --- a/plain-postgres/plain/postgres/README.md +++ b/plain-postgres/plain/postgres/README.md @@ -247,7 +247,9 @@ Post.query.where(Post.author.id.is_null()) # nullable relation `Post.author.equals(author)` raises `AttributeError` naming this spelling (an `AttributeError`, so `hasattr` and `getattr(..., default)` keep behaving). It isn't an oversight: to the type checker `Post.author` is `type[Author]`, which is what makes `Post.author.email.equals(...)` type-check, and a condition method there would be a runtime method the checker rejects. -Every relation reached _through_ a traversal is a hop, many-to-many included — `WidgetTag.widget.tags.name.equals("metal")` builds `Q(widget__tags__name="metal")` — and the same rule applies to the relation itself: `WidgetTag.widget.tags.equals(tag)` points you at `.tags.id.equals(tag.id)`. (Traversal starts from a forward foreign key; class-level many-to-many access like `Widget.tags` is not a traversal entry point.) +Traversal starts from a **forward foreign key**. Once inside one, every relation you pass through is another hop, many-to-many included — `WidgetTag.widget.tags.name.equals("metal")` builds `Q(widget__tags__name="metal")` — and the same rule applies to the relation itself: `WidgetTag.widget.tags.equals(tag)` points you at `WidgetTag.widget.tags.id.equals(tag.id)`. + +A class-level many-to-many (`Widget.tags`) is _not_ an entry point: it has no traversal wiring, and it is typed `ManyToManyManager[Tag]`, so it could not be typed as one either. Use the string path there — `Widget.query.filter(tags__name="metal")`. Reverse relations aren't traversable for the same reason (a reverse accessor is a `ClassVar`, so there is nothing for the related model to offer the checker), and the error says so. A traversed field _is_ the related field, carrying the relation path as its name — so it offers exactly the conditions that field offers, including an encrypted field's refusals. @@ -1124,6 +1126,8 @@ book.author.name # one query — loads the rest of the row The first access to any non-key field loads the whole row in a single query. There is no separate `author_id` attribute — `book.author.id` is the foreign key value, and it is type-checked because `book.author` is an `Author`. In loops, use `select_related()` to load related rows up front and avoid a query per row. +A foreign key with no value raises `RelatedObjectDoesNotExist` on access. That attribute still lives on the descriptor at runtime, but class-level access is now typed as the related model (that is what makes `Book.author.name.equals(...)` work), so `Book.author.RelatedObjectDoesNotExist` is a type error. Catch it as `Author.DoesNotExist` — the exception subclasses both that and `AttributeError` — or as `AttributeError`. + The partial-instance shortcut is safe because Plain always creates a database foreign-key constraint, so the referenced row is guaranteed to exist. ### Constraints are checked immediately diff --git a/plain-postgres/plain/postgres/agents/.claude/rules/plain-postgres.md b/plain-postgres/plain/postgres/agents/.claude/rules/plain-postgres.md index fc68820c2d..576c7b02b6 100644 --- a/plain-postgres/plain/postgres/agents/.claude/rules/plain-postgres.md +++ b/plain-postgres/plain/postgres/agents/.claude/rules/plain-postgres.md @@ -56,7 +56,11 @@ class Article(postgres.Model): A plain `Field[T]` hides them and the comparison only fails at runtime. - **JSON**: `JSONField`/`EncryptedJSONField` return `Any` from the stub (the runtime class isn't generic over its value shape), so the annotation is what - preserves typing: `Field[dict]` / `EncryptedField[dict[str, Any]]`. + preserves typing: `Field[dict]` / `EncryptedField[dict[str, Any]]`. Never + annotate a field `Field[Any]` — `Any` satisfies the model-valued `__get__` + overload, so class access types as `type[Any]` and the whole condition + surface disappears silently. Use the concrete shape, or `Field[object]` when + the column really does hold arbitrary JSON. - **Custom querysets**: declare `query: ClassVar[MyQuerySet] = MyQuerySet()` (`ClassVar` so it isn't treated as a field). Default-queryset models declare nothing — `Model.query` is typed automatically. @@ -99,9 +103,10 @@ Run `uv run plain docs postgres` for full workflow details. Use `Model.query` to build querysets (e.g., `User.query.filter(is_active=True)`). - `where()` takes typed conditions built off fields (`User.query.where(User.role.equals("admin"))`) instead of `filter()`'s string kwargs, so a typo or wrong value type is caught at the call site. -- **Conditions on a relation go through its key**: `Post.query.where(Post.author.id.equals(author.id))`, `.id.is_in([...])`, `.id.is_null()` — the typed spelling of `filter(author=author)`, same SQL. `Post.author.equals(author)` raises `AttributeError`: `Post.author` is `type[Author]` to the checker (which is what makes `Post.author.email.equals(...)` work), so it offers the related model's fields, not conditions. Many-to-many relations traverse the same way (`Widget.tags.name.equals(...)`). +- **Conditions on a relation go through its key**: `Post.query.where(Post.author.id.equals(author.id))`, `.id.is_in([...])`, `.id.is_null()` — the typed spelling of `filter(author=author)`, same SQL. `Post.author.equals(author)` raises `AttributeError`: `Post.author` is `type[Author]` to the checker (which is what makes `Post.author.email.equals(...)` work), so it offers the related model's fields, not conditions. Traversal only _starts_ from a forward FK; a many-to-many is traversable as a later hop (`WidgetTag.widget.tags.name`), but `Widget.tags` and reverse accessors are not entry points — use `filter(tags__name=...)` there. - Encrypted fields can't be looked up at all: `get_or_create(secret=...)` raises — put the value in `defaults=`. - Use `select_related()` for FK access in loops, `prefetch_related()` for reverse/M2N +- A foreign key with no value raises `RelatedObjectDoesNotExist`; catch it as `Related.DoesNotExist` (it subclasses that and `AttributeError`) — `Model.fk.RelatedObjectDoesNotExist` is a type error now that class access types as the related model. - A foreign key returns a partial related object: `obj.author` and `obj.author.id` are query-free; other fields load on first access. There is no `obj.author_id` — use `obj.author.id` - Use `.annotate(Count(...))` instead of calling `.count()` per row - Fetch all data in the view — templates should never trigger queries diff --git a/plain-postgres/plain/postgres/fields/base.py b/plain-postgres/plain/postgres/fields/base.py index 758b9dbc5c..0ae18aed90 100644 --- a/plain-postgres/plain/postgres/fields/base.py +++ b/plain-postgres/plain/postgres/fields/base.py @@ -147,6 +147,8 @@ def __str__(self) -> str: Return "package_label.model_label.field_name" for fields attached to models. """ + if self.is_lookup_reference: + return f"{self.name} (lookup reference)" if not hasattr(self, "model"): return super().__str__() model = self.model @@ -156,6 +158,8 @@ def __repr__(self) -> str: """Display the module, class, and name of the field.""" path = f"{self.__class__.__module__}.{self.__class__.__qualname__}" name = getattr(self, "name", "") + if self.is_lookup_reference: + return f"<{path} lookup reference: {name}>" if name: return f"<{path}: {name}>" return f"<{path}>" @@ -265,12 +269,26 @@ def with_lookup_prefix(self, prefix: str) -> Self: traversed field offers exactly what direct access offers -- including an encrypted field's blocks, whose error message names the full path. - The copy is not attached to a model and exists only to build a Q. + The copy is genuinely detached: it keeps only what building a Q needs + (its class, for the lookup registry, and its name). The attachment + state a real field carries is dropped, so it can't pass itself off as + a column on the related model -- `str()` would otherwise report + `examples.DeleteParent.parent__name`, and `__reduce__` would try to + look up an attribute that doesn't exist. """ prefixed = copy.copy(self) + for attached in ("model", "column", "cached_col"): + prefixed.__dict__.pop(attached, None) prefixed.name = f"{prefix}{LOOKUP_SEP}{self.name}" + prefixed.__dict__["_is_lookup_reference"] = True return prefixed + @property + def is_lookup_reference(self) -> bool: + """True for a field handed back by `with_lookup_prefix` -- a reference + to a column reached through a relation, not a column on a model.""" + return bool(self.__dict__.get("_is_lookup_reference")) + def preflight(self, **kwargs: Any) -> list[PreflightResult]: return [*self._check_field_name()] diff --git a/plain-postgres/plain/postgres/fields/encrypted.py b/plain-postgres/plain/postgres/fields/encrypted.py index 902dcf4482..8a9db50eeb 100644 --- a/plain-postgres/plain/postgres/fields/encrypted.py +++ b/plain-postgres/plain/postgres/fields/encrypted.py @@ -3,7 +3,7 @@ import base64 import json from functools import cache -from typing import TYPE_CHECKING, Any, Never +from typing import TYPE_CHECKING, Any, Literal, Never, overload try: from cryptography.fernet import Fernet, InvalidToken, MultiFernet @@ -120,25 +120,27 @@ def _decrypt(value: str) -> str: class _EncryptedExact(Exact): - """An exact lookup that rejects non-None right-hand values. + """An exact lookup that rejects right-hand values ciphertext can't match. None passes through so the ORM's exact-None → isnull rewrite in - `build_lookup` still works. Any other value could only ever match nothing - (ciphertext is non-deterministic), so it raises instead of silently - returning no rows. + `build_lookup` still works, and so does any other value the field stores + deterministically (for text, the empty string, which is stored as + plaintext ''). Anything else could only ever match nothing, so it raises + instead of silently returning no rows. """ def __init__(self, lhs: Any, rhs: Any) -> None: - if rhs is not None: - # lhs.output_field is the encrypted field itself (the lookups.py - # idiom). Its own sentence, not _lookup_unsupported_message's: - # `filter()` *is* supported here, just not against a value. - name = lhs.output_field.name + # lhs.output_field is the encrypted field itself (the lookups.py idiom), + # so it decides which values have a deterministic stored form. + field = lhs.output_field + if not field.matches_deterministically(rhs): + # Its own sentence, not _lookup_unsupported_message's: `filter()` + # *is* supported here, just not against an arbitrary value. raise TypeError( - f"Encrypted field {name!r} cannot be matched against a value: " - f"{_NON_DETERMINISTIC_EXPLANATION}. `is_null()` (or " - f"{name}=None) is the only condition it supports. If this came " - f"from get_or_create()/update_or_create(), move {name!r} into " + f"Encrypted field {field.name!r} cannot be matched against " + f"this value: {_NON_DETERMINISTIC_EXPLANATION}. " + f"{field.matchable_values_hint()} If this came from " + f"get_or_create()/update_or_create(), move {field.name!r} into " f"defaults= -- it can be written, just not looked up." ) super().__init__(lhs, rhs) @@ -199,12 +201,27 @@ def _build_q(self, method: str, suffix: str, value: Any) -> Q: through here, so blocking the ones that compare ciphertext takes a single override -- including conditions that don't exist yet. - `isnull` is the only meaningful comparison: it reads the column's - NULL-ness, not its contents. + What survives is exactly what the kwarg path allows, so + `field.equals(None)` and `filter(field=None)` can't disagree: + `isnull`, and equality against a value the field stores + deterministically. """ - if suffix != "isnull": - raise TypeError(self._lookup_unsupported_message(method)) - return super()._build_q(method, suffix, value) + if suffix == "isnull" or ( + suffix == "" and self.matches_deterministically(value) + ): + return super()._build_q(method, suffix, value) + raise TypeError(self._lookup_unsupported_message(method)) + + def matches_deterministically(self, value: Any) -> bool: + """Whether `value` has a stored form equality can actually match. + + Only NULL, in general: everything else becomes ciphertext, and + encrypting the same plaintext twice gives different bytes. + """ + return value is None + + def matchable_values_hint(self) -> str: + return ".is_null() is the only condition it supports." if TYPE_CHECKING: # The static half of the same block. `Never` as the parameter type @@ -216,9 +233,31 @@ def _build_q(self, method: str, suffix: str, value: Any) -> Q: # Declarations only -- `_build_q` above is what raises. Keeping them # here means the static block and the runtime block can't drift into # disagreeing about *how* to refuse, only about which methods exist. - def equals(self, value: Never) -> Never: ... # ty: ignore[invalid-method-override] - - def not_equal(self, value: Never) -> Never: ... # ty: ignore[invalid-method-override] + # equals/not_equal are narrowed rather than blocked: the value types + # below are exactly the ones `matches_deterministically` accepts, so + # the static surface and the runtime guard agree. Everything else is + # `Never`. + # + # The `self` restriction is what distinguishes them -- a string-valued + # encrypted column stores "" as plaintext, a JSON one doesn't -- and it + # has to live here rather than on EncryptedTextField, because models + # annotate the field `EncryptedField[T]` and that annotation is all the + # checker sees. + @overload + def equals( + self: EncryptedField[str] | EncryptedField[str | None], + value: Literal[""] | None, + ) -> Q: ... + @overload + def equals(self, value: None) -> Q: ... # ty: ignore[invalid-method-override] + + @overload + def not_equal( + self: EncryptedField[str] | EncryptedField[str | None], + value: Literal[""] | None, + ) -> Q: ... + @overload + def not_equal(self, value: None) -> Q: ... # ty: ignore[invalid-method-override] def gt(self, value: Never) -> Never: ... # ty: ignore[invalid-method-override] @@ -244,9 +283,9 @@ def _lookup_unsupported_message(self, method: str) -> str: "methods can produce a meaningful error message." ) return ( - f"Encrypted field {self.name!r} does not support .{method}(): " - f"{_NON_DETERMINISTIC_EXPLANATION}. .is_null() is the only " - f"condition it supports." + f"Encrypted field {self.name!r} does not support .{method}() " + f"against this value: {_NON_DETERMINISTIC_EXPLANATION}. " + f"{self.matchable_values_hint()}" ) def preflight(self, **kwargs: Any) -> list[PreflightResult]: @@ -332,6 +371,21 @@ def __init__( validators=validators, ) + def matches_deterministically(self, value: Any) -> bool: + # `_encrypt("")` returns "" -- the empty string is stored as plaintext, + # which is exactly what makes `default=""` expressible as a column + # DEFAULT. So equality against it is meaningful, and + # `exclude(token="")` (the documented "unset" check) keeps working. + return super().matches_deterministically(value) or ( + isinstance(value, str) and value == "" + ) + + def matchable_values_hint(self) -> str: + return ( + '.is_null() and equality against "" (stored as plaintext) are the ' + "only conditions it supports." + ) + def get_db_prep_value( self, value: Any, connection: DatabaseConnection, prepared: bool = False ) -> Any: diff --git a/plain-postgres/plain/postgres/fields/related_descriptors.py b/plain-postgres/plain/postgres/fields/related_descriptors.py index be0860eb77..f716be7cda 100644 --- a/plain-postgres/plain/postgres/fields/related_descriptors.py +++ b/plain-postgres/plain/postgres/fields/related_descriptors.py @@ -151,23 +151,35 @@ def __get__(self, instance: Any | None, cls: type | None = None) -> Any: ) return rel_obj - @cached_property - def _typed_ref(self) -> Any: + def _build_typed_ref(self) -> Any: """The traversal entry point for this relation. - A `cached_property` for the same reason `RelatedObjectDoesNotExist` is - one: at construction time `remote_field.model` may still be a string, - and it is replaced with the resolved class when the model registers. - Built once per descriptor, so class-level traversal allocates nothing - and re-imports nothing. + Built lazily, and cached by `__getattr__` rather than by + `cached_property`: this can raise `UnresolvedRelationError`, and an + AttributeError raised inside a descriptor's `__get__` makes Python + fall back to `__getattr__`, which would replace the useful message + with a bare "no attribute '_typed_ref'". The import is local because `related_typed` reaches `fields.related`, which imports this module at load time. """ - from plain.postgres.fields.related_typed import RelatedFieldRef + from plain.postgres.fields.related_typed import ( + RelatedFieldRef, + unresolved_relation_error, + ) + + try: + model = self._field.remote_field.model + except TypeError: + # `ForeignObjectRel.model` raises TypeError while the target is + # still a string, which would escape __getattr__ as a TypeError and + # make `hasattr` raise instead of returning False. + raise unresolved_relation_error( + self._field.name, str(self._field.remote_field.model_ref) + ) from None return RelatedFieldRef( - model=self._field.remote_field.model, + model=model, prefix=self._field.name, target_name=self._field.target_field.name, ) @@ -189,7 +201,10 @@ def __getattr__(self, name: str) -> Any: if name.startswith("_"): # Internals, and anything a field could never be named. raise AttributeError(name) - return getattr(self._typed_ref, name) + ref = self.__dict__.get("_typed_ref") + if ref is None: + ref = self.__dict__["_typed_ref"] = self._build_typed_ref() + return getattr(ref, name) def __set__(self, instance: Any, value: Any) -> None: """ diff --git a/plain-postgres/plain/postgres/fields/related_typed.py b/plain-postgres/plain/postgres/fields/related_typed.py index 1b4370e58f..09e853f3f7 100644 --- a/plain-postgres/plain/postgres/fields/related_typed.py +++ b/plain-postgres/plain/postgres/fields/related_typed.py @@ -19,11 +19,24 @@ from plain.postgres.exceptions import FieldDoesNotExist from plain.postgres.fields.base import CONDITION_METHODS from plain.postgres.fields.related import RelatedField +from plain.postgres.fields.reverse_descriptors import ( + ReverseForeignKey, + ReverseManyToMany, +) if TYPE_CHECKING: from plain.postgres.base import Model +class UnresolvedRelationError(AttributeError): + """A traversal reached a relation whose target model isn't resolved yet. + + An AttributeError subclass so `hasattr` and `getattr(..., default)` keep + working, but a distinct type so the timing problem is greppable rather + than looking like a typo. + """ + + class RelatedFieldRef: """Class-level proxy that walks attribute access into the related model and accumulates the lookup-path prefix as it goes. @@ -49,14 +62,8 @@ class RelatedFieldRef: """ def __init__(self, model: type[Model], prefix: str, target_name: str) -> None: - assert not isinstance(model, str), ( - f"Cannot traverse {prefix!r}: its target model is still the string " - f"{model!r}. Relation targets are replaced with the resolved class " - f"when the model is registered, so a traversal that runs at import " - f"time -- at module level, or in a default argument -- can land " - f"here before the registry is populated. Move it inside the " - f"function or method that needs it." - ) + if isinstance(model, str): + raise unresolved_relation_error(prefix, model) self._model = model self._prefix = prefix # The field on the related model that this relation targets -- the hop @@ -66,6 +73,27 @@ def __init__(self, model: type[Model], prefix: str, target_name: str) -> None: def __repr__(self) -> str: return f"" + def _is_reverse_relation(self, name: str) -> bool: + """Whether `name` names a reverse accessor on the related model. + + Reverse accessors live on the class as descriptors, not in the field + metadata, so both places are checked. + """ + try: + self._model._model_meta.get_reverse_relation(name) + except FieldDoesNotExist: + return isinstance( + getattr(self._model, name, None), + (ReverseForeignKey, ReverseManyToMany), + ) + return True + + @property + def _attribute_path(self) -> str: + """The prefix spelled the way it was written -- `widget.tags`, not + `widget__tags` -- so error messages can be pasted back into code.""" + return self._prefix.replace(LOOKUP_SEP, ".") + def __getattr__(self, name: str) -> Any: if name.startswith("_"): # Avoid infinite recursion on internals and let pickling/hasattr @@ -77,18 +105,27 @@ def __getattr__(self, name: str) -> Any: except FieldDoesNotExist: # The field lookup comes first so a related model that really does # have a column named `equals` (or `contains`, …) still traverses - # to it. An AttributeError, not a TypeError, so `hasattr` and - # `getattr(..., default)` keep behaving. + # to it. Every failure below is an AttributeError, not a TypeError, + # so `hasattr` and `getattr(..., default)` keep behaving. if name in CONDITION_METHODS: raise AttributeError( - f"{self._prefix}.{name}() is not available: " + f"{self._attribute_path}.{name}() is not available: " f"{self._prefix!r} is a relation, not a field. Build the " f"condition on the key it points at instead -- " - f"{self._prefix}.{self._target_name}.{name}(...), which " - f"compiles to the same SQL." + f"{self._attribute_path}.{self._target_name}.{name}(...), " + f"which compiles to the same SQL." + ) from None + if self._is_reverse_relation(name): + raise AttributeError( + f"{self._attribute_path}.{name} is a reverse relation, " + f"which the typed API cannot traverse: a reverse accessor " + f"is a ClassVar, so there is nothing for " + f"`{self._model.__name__}` to offer the type checker here. " + f"Use the string path instead -- " + f"filter({self._prefix}{LOOKUP_SEP}{name}{LOOKUP_SEP}...=...)." ) from None raise AttributeError( - f"{self._prefix}.{name} is not a traversable field or relation" + f"{self._attribute_path}.{name} is not a traversable field or relation" ) from None if isinstance(field, RelatedField): @@ -103,3 +140,18 @@ def __getattr__(self, name: str) -> Any: target_name=field.target_field.name, ) return field.with_lookup_prefix(self._prefix) + + +def unresolved_relation_error(prefix: str, target: str) -> UnresolvedRelationError: + """The error for a traversal that outran model registration. + + Relation targets are replaced with the resolved class when the model + registers, so a traversal evaluated at import time -- at module level, or + in a default argument -- can run before the registry is populated. + """ + return UnresolvedRelationError( + f"Cannot traverse {prefix!r}: its target model {target!r} hasn't been " + f"resolved yet. Relation targets are resolved when the model is " + f"registered, so this traversal is running too early -- move it inside " + f"the function or method that needs it." + ) diff --git a/plain-postgres/tests/internal/test_typed_where_internals.py b/plain-postgres/tests/internal/test_typed_where_internals.py index 336dd13d7f..c430f4204b 100644 --- a/plain-postgres/tests/internal/test_typed_where_internals.py +++ b/plain-postgres/tests/internal/test_typed_where_internals.py @@ -68,7 +68,13 @@ def test_traversal_before_the_target_resolves_says_so(): that runs at import time can land here first, and the old code dereferenced `str._model_meta` -- an AttributeError that `hasattr` then swallowed, so the symptom was a missing attribute rather than a timing problem.""" - from plain.postgres.fields.related_typed import RelatedFieldRef + from plain.postgres.fields.related_typed import ( + RelatedFieldRef, + UnresolvedRelationError, + ) - with pytest.raises(AssertionError, match=r"still the string 'Tag'"): + with pytest.raises(UnresolvedRelationError, match=r"'Tag' hasn't been resolved"): RelatedFieldRef(model="Tag", prefix="tags", target_name="id") # ty: ignore[invalid-argument-type] + + # An AttributeError subclass, so the attribute protocol still holds. + assert issubclass(UnresolvedRelationError, AttributeError) diff --git a/plain-postgres/tests/public/test_encrypted_fields.py b/plain-postgres/tests/public/test_encrypted_fields.py index 30583908a0..3dfdc82740 100644 --- a/plain-postgres/tests/public/test_encrypted_fields.py +++ b/plain-postgres/tests/public/test_encrypted_fields.py @@ -167,11 +167,11 @@ class TestTypedQueryMethodsBlocked: def test_equals_raises(self): with pytest.raises(TypeError, match=r"api_key.*does not support \.equals\("): - SecretStore.api_key.equals("anything") # ty: ignore[invalid-argument-type] + SecretStore.api_key.equals("anything") # ty: ignore[no-matching-overload] def test_not_equal_raises(self): with pytest.raises(TypeError, match=r"does not support \.not_equal\("): - SecretStore.api_key.not_equal("x") # ty: ignore[invalid-argument-type] + SecretStore.api_key.not_equal("x") # ty: ignore[no-matching-overload] @pytest.mark.parametrize("method", ["gt", "gte", "lt", "lte"]) def test_ordering_comparison_raises(self, method): @@ -205,9 +205,9 @@ def test_every_blocked_method_is_rejected_statically(self): checker are pinned to each other in one place. """ with pytest.raises(TypeError): - SecretStore.api_key.equals("x") # ty: ignore[invalid-argument-type] + SecretStore.api_key.equals("x") # ty: ignore[no-matching-overload] with pytest.raises(TypeError): - SecretStore.api_key.not_equal("x") # ty: ignore[invalid-argument-type] + SecretStore.api_key.not_equal("x") # ty: ignore[no-matching-overload] with pytest.raises(TypeError): SecretStore.api_key.gt("x") # ty: ignore[invalid-argument-type] with pytest.raises(TypeError): @@ -233,9 +233,9 @@ def test_json_field_is_rejected_statically(self): rather than through getattr, because a marker can't bind to a dynamic call.""" with pytest.raises(TypeError): - SecretStore.config.equals({"a": 1}) # ty: ignore[invalid-argument-type] + SecretStore.config.equals({"a": 1}) # ty: ignore[no-matching-overload] with pytest.raises(TypeError): - SecretStore.config.not_equal({"a": 1}) # ty: ignore[invalid-argument-type] + SecretStore.config.not_equal({"a": 1}) # ty: ignore[no-matching-overload] with pytest.raises(TypeError): SecretStore.config.is_in([{"a": 1}]) # ty: ignore[invalid-argument-type] @@ -279,15 +279,11 @@ class TestKwargFilterBlocked: """ def test_filter_non_none_raises(self, db): - with pytest.raises( - TypeError, match=r"api_key.*cannot be matched against a value" - ): + with pytest.raises(TypeError, match=r"api_key.*cannot be matched against"): SecretStore.query.filter(api_key="sk-test").count() def test_exclude_non_none_raises(self, db): - with pytest.raises( - TypeError, match=r"api_key.*cannot be matched against a value" - ): + with pytest.raises(TypeError, match=r"api_key.*cannot be matched against"): SecretStore.query.exclude(api_key="sk-test").count() def test_filter_none_still_rewrites_to_isnull(self, db): @@ -319,9 +315,7 @@ def test_get_or_create_with_the_encrypted_value_in_defaults_works(self, db): def test_filter_against_an_expression_raises(self, db): """An F() right-hand side is a value comparison too — the column is still ciphertext, so it can never match.""" - with pytest.raises( - TypeError, match=r"api_key.*cannot be matched against a value" - ): + with pytest.raises(TypeError, match=r"api_key.*cannot be matched against"): SecretStore.query.filter(api_key=F("name")).count() @@ -389,3 +383,55 @@ def test_literal_default_is_still_rejected(self, value): with pytest.raises(TypeError, match="does not accept a persistent default"): EncryptedJSONField(required=False, allow_null=True, default=value) + + +class TestDeterministicValuesStillMatch: + """The empty string is stored as plaintext `''` (that is what makes + `default=""` expressible as a column DEFAULT), so equality against it is + meaningful and must keep working -- on both the kwarg and the typed path, + which must agree with each other.""" + + def test_filter_on_empty_string_works(self, db): + SecretStore.query.create(name="blank", api_key="k", notes="", config=None) + SecretStore.query.create(name="filled", api_key="k", notes="x", config=None) + + assert SecretStore.query.filter(notes="").count() == 1 + assert SecretStore.query.exclude(notes="").count() == 1 + + def test_get_or_create_on_empty_string_works(self, db): + obj, created = SecretStore.query.get_or_create( + notes="", defaults={"name": "blank", "api_key": "k", "config": None} + ) + assert created + again, created_again = SecretStore.query.get_or_create( + notes="", defaults={"name": "other", "api_key": "k", "config": None} + ) + assert not created_again + assert again.id == obj.id + + def test_typed_equals_matches_the_kwarg_path(self): + """`equals(None)` and `filter(field=None)` can't disagree, and the + error message advertises `=None`, so the typed path has to allow it.""" + assert SecretStore.api_key.equals(None).children == [("api_key", None)] + assert SecretStore.notes.equals("").children == [("notes", "")] + assert SecretStore.notes.not_equal("").children == [("notes", "")] + + def test_where_filters_on_the_empty_string(self, db): + SecretStore.query.create(name="blank", api_key="k", notes="", config=None) + SecretStore.query.create(name="filled", api_key="k", notes="x", config=None) + + rows = list(SecretStore.query.where(SecretStore.notes.equals(""))) + assert [r.name for r in rows] == ["blank"] + + def test_a_real_value_is_still_blocked(self, db): + with pytest.raises(TypeError, match=r"does not support \.equals\("): + SecretStore.notes.equals("something") # ty: ignore[no-matching-overload] + with pytest.raises(TypeError, match=r"cannot be matched against"): + SecretStore.query.filter(notes="something").count() + + def test_json_field_allows_none_but_not_empty_string(self): + """Only text stores "" as plaintext; an empty string on a JSON column + would still be encrypted, so it stays blocked.""" + assert SecretStore.config.equals(None).children == [("config", None)] + with pytest.raises(TypeError, match=r"does not support \.equals\("): + SecretStore.config.equals("") # ty: ignore[no-matching-overload] diff --git a/plain-postgres/tests/public/test_typed_where_fk.py b/plain-postgres/tests/public/test_typed_where_fk.py index d2281b0d35..19c28f614d 100644 --- a/plain-postgres/tests/public/test_typed_where_fk.py +++ b/plain-postgres/tests/public/test_typed_where_fk.py @@ -330,7 +330,8 @@ def test_condition_on_a_traversed_m2m_gets_the_same_advice(): message = str(excinfo.value) assert "is a relation, not a field" in message - assert "widget__tags.id.equals(...)" in message + assert "WidgetTag.widget.tags" not in message # the prefix, not the model + assert "widget.tags.id.equals(...)" in message def test_where_filters_through_a_foreign_key_then_an_m2m(db): @@ -346,3 +347,15 @@ def test_where_filters_through_a_foreign_key_then_an_m2m(db): ) rows = list(WidgetTag.query.where(condition)) assert [r.widget.id for r in rows] == [cog.id] + + +def test_reverse_relation_says_it_is_not_traversable(): + """`filter(parent__childcascade_set__...)` works, so "not a traversable + field or relation" would be a lie -- the typed API is what can't express + it, and the message has to say which.""" + with pytest.raises(AttributeError) as excinfo: + getattr(ChildCascade.parent, "childcascade_set") + + message = str(excinfo.value) + assert "reverse relation" in message + assert "filter(parent__childcascade_set__...=...)" in message