Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
ad3f25a
Add typed where() with field-method conditions
davegaeddert May 23, 2026
1f02cf0
Block typed-query comparisons on encrypted fields
davegaeddert May 23, 2026
0fc51f2
Close kwarg-path foot-gun and tighten encrypted-field block
davegaeddert May 25, 2026
f76757c
Add typed FK traversal for where() clauses
davegaeddert May 25, 2026
5a78e38
Fix FK traversal review findings
davegaeddert May 25, 2026
1807490
Close assignment-typing gap on Field.__set__
davegaeddert May 25, 2026
bc619bb
Merge master into typed-where
davegaeddert May 25, 2026
9cf1090
Merge remote-tracking branch 'origin/master' into typed-where
davegaeddert Jul 23, 2026
f32c3ad
Fix FK descriptor shadowing in typed where() traversal
davegaeddert Jul 23, 2026
85717fd
Add is_in membership condition to typed where()
davegaeddert Jul 23, 2026
3da7f53
Document typed where() conditions
davegaeddert Jul 23, 2026
c9e8beb
Consolidate typed where() traversal onto delegation and metadata reso…
davegaeddert Jul 23, 2026
9c55214
Add Selectable[T] base shared by fields and expressions
davegaeddert Jul 23, 2026
605bc4c
Add typed QuerySet.select() returning honest rows
davegaeddert Jul 23, 2026
4fbeb51
Test and document select()
davegaeddert Jul 23, 2026
47465aa
Restore Selectable[T] select() ladder and tidy select() internals
davegaeddert Jul 23, 2026
a289af1
Merge origin/master into typed-where
davegaeddert Sep 18, 2026
af0eb97
Fix typed where() against master: new lint rules, ty overrides, encry…
davegaeddert Sep 18, 2026
eeb6699
Merge typed-where (with master) into select
davegaeddert Sep 18, 2026
b7d2bc2
Format select docs and imports for master's ruff defaults
davegaeddert Sep 18, 2026
4d3cb34
Merge origin/master into typed-where
davegaeddert Sep 18, 2026
14b7391
Merge branch 'typed-where' into select
davegaeddert Sep 18, 2026
d6ec8de
Merge remote-tracking branch 'origin/master' into typed-where
davegaeddert Sep 18, 2026
158add9
Merge branch 'typed-where' into select
davegaeddert Sep 18, 2026
69c757e
Anchor the reset test on the leaf migration's own models
davegaeddert Sep 18, 2026
e0c3c7f
Pin the encrypted-field block from the type checker's side
davegaeddert Sep 18, 2026
7921355
Merge branch 'typed-where' into select
davegaeddert Sep 18, 2026
6e0bbd1
Converge the reset test on the master-bound version
davegaeddert Sep 18, 2026
9b5c91f
Merge branch 'typed-where' into select
davegaeddert Sep 18, 2026
fcc201a
Merge origin/master into typed-where
davegaeddert Sep 19, 2026
dd38bf1
Carry the typed query surface through #83's Field[T] annotations
davegaeddert Sep 19, 2026
68107d6
Merge branch 'typed-where' into select
davegaeddert Sep 19, 2026
fc66c73
Give every string-valued field the pattern conditions Field declares
davegaeddert Sep 19, 2026
1ce57ae
Merge branch 'typed-where' into select
davegaeddert Sep 19, 2026
f37b02c
Address Codex review on #84: FK conditions, README example, descripto…
davegaeddert Sep 19, 2026
ffa185a
Merge branch 'typed-where' into select
davegaeddert Sep 19, 2026
cb5bc3c
Put the whole condition surface on Field
davegaeddert Sep 19, 2026
89f8a76
Class-level FK access returns the descriptor again
davegaeddert Sep 19, 2026
d1af6c7
Merge branch 'typed-where' into select
davegaeddert Sep 19, 2026
cb7db9a
Address code-review findings on the typed read surface
davegaeddert Sep 19, 2026
1e84052
Merge branch 'typed-where' into select
davegaeddert Sep 19, 2026
25f06e8
Address xhigh review: encrypted empty string, traversal edges, detach…
davegaeddert Sep 19, 2026
2f9225d
Merge branch 'typed-where' into select
davegaeddert Sep 19, 2026
9fec2a5
Merge remote-tracking branch 'origin/master' into select
davegaeddert Sep 19, 2026
9f885a1
Merge remote-tracking branch 'origin/master' into select
davegaeddert Sep 19, 2026
b825172
plain-postgres: move the select() type claims into the typing corpus
davegaeddert Sep 19, 2026
7b924c5
plain-postgres: drop Selectable's two scaffolds
davegaeddert Sep 19, 2026
0d356aa
plain-postgres: let select() take F() like values_list() does
davegaeddert Sep 19, 2026
79c3f74
plain-postgres: update() refuses a row-mode queryset, like delete()
davegaeddert Sep 19, 2026
2549a26
plain-postgres: fix select()'s column and result_type edges
davegaeddert Sep 19, 2026
1a4f0fd
plain-postgres: cover select()'s fixed edges and document its limits
davegaeddert Sep 19, 2026
86feed2
plain-postgres: map result_type columns onto the constructor, not fie…
davegaeddert Sep 19, 2026
0bb3f36
plain-postgres: refuse annotate() after select()
davegaeddert Sep 19, 2026
a447420
plain-postgres: pass each result_type column by the kind its paramete…
davegaeddert Sep 19, 2026
5ce62e9
plain-postgres: annotate chaining QuerySet methods Self so subclasses…
davegaeddert Sep 19, 2026
fc6c97d
plain-postgres: route select()'s internal annotate off the public method
davegaeddert Sep 20, 2026
7e4b60e
plain-postgres: refuse prefetch_related() together with select()
davegaeddert Sep 20, 2026
290fb8a
plain-postgres: stop internal expression aliases from colliding
davegaeddert Sep 20, 2026
a5e7350
plain-postgres: guard both sides when merging querysets
davegaeddert Sep 20, 2026
260a88a
plain-postgres: finish RowQuerySet's row type and write refusals
davegaeddert Sep 20, 2026
6724074
plain-postgres: cover the re-select matrix and document distinct()'s …
davegaeddert Sep 20, 2026
30d8e1a
Merge origin/master into select
davegaeddert Sep 20, 2026
ef6dfcd
plain-postgres: reject columns built from a different model's fields
davegaeddert Sep 20, 2026
6383297
plain-postgres: tidy select()'s remaining rough edges
davegaeddert Sep 20, 2026
006b751
Merge origin/master into select
davegaeddert Sep 20, 2026
63c0df4
Merge origin/master into select
davegaeddert Sep 20, 2026
3e28b26
plain-postgres: refuse bulk_update() and returning() on a select() qu…
davegaeddert Sep 20, 2026
46d158a
plain-postgres: assert every rung of the select() overload ladder
davegaeddert Sep 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions plain-postgres/plain/postgres/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,77 @@ A traversed field _is_ the related field, carrying the relation path as its name

[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`.

### Selecting columns with select()

`select()` pulls back specific columns as typed rows instead of model instances. You pass typed field references, and a type checker knows the exact shape of each row:

```python
from plain.postgres import Field, types


@postgres.register_model
class User(postgres.Model):
email: Field[str] = types.EmailField()
age: Field[int | None] = types.IntegerField(allow_null=True, default=None)


# list-like of tuple[str, int | None], precisely typed
rows = User.query.where(User.age.gte(18)).select(User.email, User.age)
for email, age in rows:
...
```

There are three modes:

- **Tuples** (default) — one tuple per row, typed per column: `select(User.email, User.age)` yields `tuple[str, int | None]`.
- **Flat scalars** — a single column unwrapped, with `flat=True`: `select(User.email, flat=True)` yields `str`. `flat=True` accepts exactly one column.
- **Dataclasses** — map each column onto a dataclass with `result_type=`: `select(User.email, User.age, result_type=UserStats)` yields `UserStats`. Columns map to dataclass fields **positionally**, so the selection order must match the dataclass field order, and each selected field's name must match the dataclass field at the same position.

```python
from dataclasses import dataclass


@dataclass
class UserStats:
email: str
age: int | None


stats = User.query.select(User.email, User.age, result_type=UserStats)
```

The return value is a [`RowQuerySet`](./query.py#RowQuerySet) — that is the name to reach for when you need to annotate one:

```python
from plain.postgres import RowQuerySet


def adults() -> RowQuerySet[tuple[str, int | None]]:
return User.query.where(User.age.gte(18)).select(User.email, User.age)
```

You can select expression columns too — `select(User.id, Sum("amount"))`, or an `F()` — but an expression column types as `Any` (its output type isn't tracked yet). The fields around it stay precise, so `select(User.id, Sum("amount"))` types as `tuple[int, Any]`.

Per-column typing runs to **ten columns**. An eleventh is still selected and still returns rows, but the row type degrades to `tuple[Any, ...]` — reach for `result_type=` when a row is that wide.

`select()` goes last in a chain: `annotate()` must come before it, because an annotation appends a column and would change the row shape out from under the type `select()` declared. `annotate()` after `select()` raises `TypeError` saying so. `prefetch_related()` is refused in both orders — a prefetch attaches related objects to a model instance's attributes, and a row has nowhere to put them; select the columns you need from the related model instead.

Re-selecting replaces the **column list**, not the joins: an expression that reached through a relation (`select(Upper("tags__name"))`) leaves its join in place, so a later `select(Widget.name)` still returns one row per joined row — and `count()`/`exists()` count those. This is `annotate(...)` followed by `values_list(...)` behaving as it always has; trimming joins no queryset needs any more is out of scope here.

`distinct()` with an `order_by()` on a column you didn't select returns duplicates: the ordering column has to go into the `SELECT` list for Postgres to sort by it, so `SELECT DISTINCT` deduplicates on that column too. Order by something you selected, or drop the ordering. This is `values_list()`'s behavior as well, not new to `select()`.

Columns annotated `Field[Any]` are rejected by `select()`, because `Any` satisfies the model-valued `__get__` overload and class access resolves as `type[Any]` rather than a field. That's the same reason the field-annotation guidance says never to annotate a field `Field[Any]` — use the concrete type, or `Field[object]` when the column really does hold arbitrary JSON, which `select()` types as `object`.

**`select()` returns rows, not partial model instances.** This is deliberate: a model instance with only some columns loaded is a type-level lie — the type checker thinks every field is present, so touching an unselected column looks fine but fails or fires a hidden query at runtime. Honest tuples/dataclasses keep the types truthful. As a result, iteration, `first()`, `get()`, `iterator()`, and slicing all return rows, and anything that would read or write model rows, or change the selected columns — `update()`, `delete()`, `get_or_create()`, `values()`, `values_list()`, `annotate()`, `prefetch_related()` — raises `TypeError`. `update()` and `delete()` refuse a queryset in row mode however it got there, `values()` and `values_list()` included.

`select()` takes typed references only — a bare string like `select("email")` raises `TypeError` (use `User.email`).

**A column belongs to the model whose field built it**, the same as [a condition does](#querying-with-typed-conditions): `Order.query.select(User.email)` raises `TypeError` naming both models. A type checker can't catch it — `Field[str]` is `Field[str]` whichever model declared it — and without the check the name `"email"` just resolves against `Order`, silently the wrong column when both models have one. Expressions are unaffected: `F("email")` and `Upper("email")` take a string resolved against whatever query they land in, like `filter()`'s kwargs.

**Relations are not selectable yet.** `select(Post.author)` (the relation) and `select(Post.author.city)` (a column through it) both raise `TypeError`, and so does `select(Post.author.id)` — the foreign key column itself. The reason is nullability: a column reached through a relation arrives over a join, so a nullable relation yields `None` where the traversed field's type says it can't. Until `select()` can express that, `values_list("author__id", flat=True)` is the spelling, and the error message names it.

**`select()` hands back a plain `RowQuerySet`, not your custom QuerySet subclass.** Chain your own methods before `select()`, not after — `User.query.active().select(...)` works, `User.query.select(...).active()` raises `AttributeError`.

### 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 as a `ClassVar` (so it isn't treated as a constructor field):
Expand Down
3 changes: 2 additions & 1 deletion plain-postgres/plain/postgres/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
)
from .indexes import Index
from .options import Options
from .query import QuerySet
from .query import QuerySet, RowQuerySet
from .query_utils import Q
from . import types

Expand Down Expand Up @@ -98,6 +98,7 @@
"RandomStringField",
"ReverseForeignKey",
"ReverseManyToMany",
"RowQuerySet",
"SmallIntegerField",
"TextChoices",
"TextField",
Expand Down
5 changes: 3 additions & 2 deletions plain-postgres/plain/postgres/expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
FullResultSet,
)
from plain.postgres.query_utils import Q
from plain.postgres.selectable import Selectable
from plain.utils.deconstruct import deconstructible
from plain.utils.hashable import make_hashable

Expand Down Expand Up @@ -220,7 +221,7 @@ def __invert__(self) -> NegatedExpression:
return NegatedExpression(self)


class BaseExpression:
class BaseExpression(Selectable[Any]):
"""Base class for all query expressions."""

empty_result_set_value = NotImplemented
Expand Down Expand Up @@ -809,7 +810,7 @@ def as_sql(


@deconstructible(path="plain.postgres.F")
class F(Combinable):
class F(Combinable, Selectable[Any]):
"""An object capable of resolving references to existing query objects."""

def __init__(self, name: str):
Expand Down
2 changes: 0 additions & 2 deletions plain-postgres/plain/postgres/fields/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from .base import DATABASE_DEFAULT as DATABASE_DEFAULT
from .base import (
NOT_PROVIDED,
Empty,
Field,
)
from .base import ChoicesField as ChoicesField
Expand Down Expand Up @@ -34,7 +33,6 @@
"DecimalField",
"DurationField",
"EmailField",
"Empty",
"Field",
"FloatField",
"GenericIPAddressField",
Expand Down
27 changes: 13 additions & 14 deletions plain-postgres/plain/postgres/fields/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from plain.postgres.dialect import quote_name
from plain.postgres.enums import ChoicesMeta
from plain.postgres.query_utils import Q, RegisterLookupMixin
from plain.postgres.selectable import Selectable
from plain.preflight import PreflightResult
from plain.utils.datastructures import DictWrapper
from plain.utils.functional import Promise
Expand All @@ -34,10 +35,6 @@
from plain.postgres.sql.compiler import SQLCompiler


class Empty:
pass


class NOT_PROVIDED:
pass

Expand Down Expand Up @@ -91,17 +88,20 @@ def _load_field(
# except for ForeignKeys, where the "_id" suffix is appended.


def _empty(of_cls: type) -> Empty:
new = Empty()
new.__class__ = of_cls
return new
def _empty(of_cls: type) -> Any:
"""Build an initialized-but-unpopulated instance of `of_cls`.

Module-level (not a lambda or a method) because `__reduce__` names it as
the pickle reconstructor.
"""
return object.__new__(of_cls)


# Ordering conditions: the ones a None operand is meaningless for.
_ORDERING_SUFFIXES = frozenset({"gt", "gte", "lt", "lte"})


class Field[T](RegisterLookupMixin):
class Field[T](Selectable[T], RegisterLookupMixin):
"""Base class for all field types"""

# SQL type for this field (e.g. "text", "integer", "boolean").
Expand Down Expand Up @@ -434,12 +434,11 @@ def __deepcopy__(self, memodict: dict[int, Any]) -> Self:
return obj

def __copy__(self) -> Self:
# We need to avoid hitting __reduce__, so define this
# slightly weird copy construct.
obj = Empty()
obj.__class__ = self.__class__
# Build the instance directly rather than calling the constructor,
# which would hit __reduce__.
obj = object.__new__(self.__class__)
obj.__dict__ = self.__dict__.copy()
return cast(Self, obj)
return obj

def __reduce__(
self,
Expand Down
Loading
Loading