1"""Field references in admin declarations.
2
3`search_fields`, `queryset_order` and a `TrendCard`'s datetime/group fields are
4declared as either a typed field reference (`User.email`, or the traversed
5`FlagResult.flag.name`) or the lookup path as a string. Both spellings are
6normalized to the path, so every query site downstream builds the same
7`Q(**{f"{path}__icontains": ...})` / `order_by(path)` it always built -- the
8SQL is identical whichever spelling the declaration used.
9
10Normalizing happens twice, for two different reasons:
11
12- **At class definition**, over a plain tuple (or a plain `Field`/`str`) found
13 on the class. That is where a reference to another model's field can be
14 refused while the traceback still points at the declaration -- and it also
15 takes the `Field` back off the class attribute. A `Field` is a data
16 descriptor, so one left sitting on a view or card class would run on every
17 `self.search_fields`, which is not what the attribute means there.
18- **At runtime**, over whatever the instance actually has. A declaration can
19 be a `property`, or replaced on the instance from `get()`, and neither goes
20 through class definition.
21
22Strings stay supported because not every path is expressible as a reference:
23traversal has to *start* at a forward foreign key, so a path that starts at a
24reverse accessor or a many-to-many (`"memberships__team__name"`) has no field
25to reference.
26"""
27
28from __future__ import annotations
29
30import inspect
31from typing import Any
32
33from plain.postgres import Field, Model
34
35# What a field-taking admin declaration accepts.
36type FieldRef = Field[Any] | str
37
38
39def field_lookup_path(
40 ref: FieldRef, *, model: type[Model] | None, declared_as: str
41) -> str:
42 """Normalize one declared reference to its lookup path.
43
44 `model` is the model the declaring view or card queries, or None when
45 there is nothing to check against (an `AdminListView` over plain objects).
46 `declared_as` names the declaration in the error, e.g.
47 `"UserAdmin.ListView.search_fields"`.
48 """
49 if isinstance(ref, str):
50 return ref
51
52 if not ref.lookup_path:
53 # A field declared on a `ModelMixin`, or built standalone for an
54 # aggregate, was never named -- only the model that mixes it in has an
55 # attached copy. Left alone it normalizes to "" and surfaces much
56 # later as `FieldError: Cannot resolve keyword ''`.
57 raise TypeError(
58 f"{declared_as} references an unattached "
59 f"{type(ref).__name__} -- a field declared on a mixin, or built "
60 f"on its own, carries no name to look up. Reference the field on "
61 f"the model that declares it."
62 )
63
64 # A field reference carries the model a condition built from it would
65 # belong to -- the declaring model for a column, the model the traversal
66 # started from for `FlagResult.flag.name`. The same identity where()
67 # checks, checked here instead, because these declarations are turned into
68 # string lookups and would otherwise resolve silently against the wrong
69 # column.
70 source = ref.source_model
71 if model is not None and source is not None and source is not model:
72 raise TypeError(
73 f"{declared_as} references {source.__name__}.{ref.lookup_path}, "
74 f"but this is a {model.__name__} view. Reference "
75 f"{model.__name__}'s own field, or traverse to it from "
76 f"{model.__name__}."
77 )
78
79 return ref.lookup_path
80
81
82def field_lookup_paths(
83 refs: tuple[FieldRef, ...], *, model: type[Model] | None, declared_as: str
84) -> tuple[str, ...]:
85 """Normalize a declared tuple of references to lookup paths."""
86 return tuple(
87 field_lookup_path(ref, model=model, declared_as=declared_as) for ref in refs
88 )
89
90
91def _declared(obj: Any, attr: str) -> Any:
92 """Read `attr` off a class or instance without running a descriptor.
93
94 Class definition has to see the declaration itself, not what `Field`'s
95 descriptor would hand back; an instance read has to see its own override
96 rather than the class default. `getattr_static` does both.
97 """
98 return inspect.getattr_static(obj, attr, None)
99
100
101def converge_declared_tuple(cls: type, attr: str, *, model: type[Model] | None) -> None:
102 """Normalize a tuple of field references declared on `cls`, in place.
103
104 Anything that isn't a plain tuple -- a `property`, a method, a descriptor
105 of someone else's -- is left alone; it computes its value per instance, so
106 it is normalized at runtime instead.
107 """
108 raw = _declared(cls, attr)
109 if not isinstance(raw, tuple):
110 return
111
112 paths = field_lookup_paths(
113 raw, model=model, declared_as=f"{cls.__qualname__}.{attr}"
114 )
115 if paths != raw:
116 setattr(cls, attr, paths)
117
118
119def converge_declared_field(cls: type, attr: str, *, model: type[Model] | None) -> None:
120 """Normalize a single field reference declared on `cls`, in place."""
121 raw = _declared(cls, attr)
122 if not isinstance(raw, Field | str):
123 return
124
125 path = field_lookup_path(raw, model=model, declared_as=f"{cls.__qualname__}.{attr}")
126 if path != raw:
127 setattr(cls, attr, path)
128
129
130def instance_field_ref(obj: Any, attr: str) -> FieldRef | None:
131 """Read a single `FieldRef | None` declaration off a live instance.
132
133 The static read is what picks up an instance's own override. Anything it
134 finds that isn't a reference already is something that computes one -- a
135 `property`, say -- so that one is asked for normally.
136 """
137 ref = _declared(obj, attr)
138 if ref is None or isinstance(ref, Field | str):
139 return ref
140 return getattr(obj, attr)