plain.postgres 0.84.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plain/postgres/CHANGELOG.md +1028 -0
- plain/postgres/README.md +925 -0
- plain/postgres/__init__.py +120 -0
- plain/postgres/agents/.claude/rules/plain-postgres.md +78 -0
- plain/postgres/aggregates.py +236 -0
- plain/postgres/backups/__init__.py +0 -0
- plain/postgres/backups/cli.py +148 -0
- plain/postgres/backups/clients.py +94 -0
- plain/postgres/backups/core.py +172 -0
- plain/postgres/base.py +1415 -0
- plain/postgres/cli/__init__.py +3 -0
- plain/postgres/cli/db.py +142 -0
- plain/postgres/cli/migrations.py +1085 -0
- plain/postgres/config.py +18 -0
- plain/postgres/connection.py +1331 -0
- plain/postgres/connections.py +77 -0
- plain/postgres/constants.py +13 -0
- plain/postgres/constraints.py +495 -0
- plain/postgres/database_url.py +94 -0
- plain/postgres/db.py +59 -0
- plain/postgres/default_settings.py +38 -0
- plain/postgres/deletion.py +475 -0
- plain/postgres/dialect.py +640 -0
- plain/postgres/entrypoints.py +4 -0
- plain/postgres/enums.py +103 -0
- plain/postgres/exceptions.py +217 -0
- plain/postgres/expressions.py +1912 -0
- plain/postgres/fields/__init__.py +2118 -0
- plain/postgres/fields/encrypted.py +354 -0
- plain/postgres/fields/json.py +413 -0
- plain/postgres/fields/mixins.py +30 -0
- plain/postgres/fields/related.py +1192 -0
- plain/postgres/fields/related_descriptors.py +290 -0
- plain/postgres/fields/related_lookups.py +223 -0
- plain/postgres/fields/related_managers.py +661 -0
- plain/postgres/fields/reverse_descriptors.py +229 -0
- plain/postgres/fields/reverse_related.py +328 -0
- plain/postgres/fields/timezones.py +143 -0
- plain/postgres/forms.py +773 -0
- plain/postgres/functions/__init__.py +189 -0
- plain/postgres/functions/comparison.py +127 -0
- plain/postgres/functions/datetime.py +454 -0
- plain/postgres/functions/math.py +140 -0
- plain/postgres/functions/mixins.py +59 -0
- plain/postgres/functions/text.py +282 -0
- plain/postgres/functions/window.py +125 -0
- plain/postgres/indexes.py +286 -0
- plain/postgres/lookups.py +758 -0
- plain/postgres/meta.py +584 -0
- plain/postgres/migrations/__init__.py +53 -0
- plain/postgres/migrations/autodetector.py +1379 -0
- plain/postgres/migrations/exceptions.py +54 -0
- plain/postgres/migrations/executor.py +188 -0
- plain/postgres/migrations/graph.py +364 -0
- plain/postgres/migrations/loader.py +377 -0
- plain/postgres/migrations/migration.py +180 -0
- plain/postgres/migrations/operations/__init__.py +34 -0
- plain/postgres/migrations/operations/base.py +139 -0
- plain/postgres/migrations/operations/fields.py +373 -0
- plain/postgres/migrations/operations/models.py +798 -0
- plain/postgres/migrations/operations/special.py +184 -0
- plain/postgres/migrations/optimizer.py +74 -0
- plain/postgres/migrations/questioner.py +340 -0
- plain/postgres/migrations/recorder.py +119 -0
- plain/postgres/migrations/serializer.py +378 -0
- plain/postgres/migrations/state.py +882 -0
- plain/postgres/migrations/utils.py +147 -0
- plain/postgres/migrations/writer.py +302 -0
- plain/postgres/options.py +207 -0
- plain/postgres/otel.py +231 -0
- plain/postgres/preflight.py +336 -0
- plain/postgres/query.py +2242 -0
- plain/postgres/query_utils.py +456 -0
- plain/postgres/registry.py +217 -0
- plain/postgres/schema.py +1885 -0
- plain/postgres/sql/__init__.py +40 -0
- plain/postgres/sql/compiler.py +1869 -0
- plain/postgres/sql/constants.py +22 -0
- plain/postgres/sql/datastructures.py +222 -0
- plain/postgres/sql/query.py +2947 -0
- plain/postgres/sql/where.py +374 -0
- plain/postgres/test/__init__.py +0 -0
- plain/postgres/test/pytest.py +117 -0
- plain/postgres/test/utils.py +18 -0
- plain/postgres/transaction.py +222 -0
- plain/postgres/types.py +92 -0
- plain/postgres/types.pyi +751 -0
- plain/postgres/utils.py +345 -0
- plain_postgres-0.84.0.dist-info/METADATA +937 -0
- plain_postgres-0.84.0.dist-info/RECORD +93 -0
- plain_postgres-0.84.0.dist-info/WHEEL +4 -0
- plain_postgres-0.84.0.dist-info/entry_points.txt +5 -0
- plain_postgres-0.84.0.dist-info/licenses/LICENSE +61 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Reverse relation descriptors for explicit reverse relation declarations.
|
|
3
|
+
|
|
4
|
+
This module contains descriptors for the reverse side of ForeignKeyField and
|
|
5
|
+
ManyToManyField relations, allowing explicit declaration of reverse accessors
|
|
6
|
+
without relying on automatic related_name generation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from plain.postgres import Model
|
|
15
|
+
from plain.postgres.fields.related_managers import BaseRelatedManager
|
|
16
|
+
from plain.postgres.query import QuerySet
|
|
17
|
+
|
|
18
|
+
T = TypeVar("T", bound="Model")
|
|
19
|
+
# Default to QuerySet[Any] so users can omit the second type parameter
|
|
20
|
+
QS = TypeVar("QS", bound="QuerySet[Any]", default="QuerySet[Any]")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BaseReverseDescriptor(Generic[T, QS]):
|
|
24
|
+
"""
|
|
25
|
+
Base class for reverse relation descriptors.
|
|
26
|
+
|
|
27
|
+
Provides common functionality for ReverseForeignKey and ReverseManyToMany
|
|
28
|
+
descriptors, including field resolution, validation, and the descriptor protocol.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, to: str | type[T], field: str):
|
|
32
|
+
self.to = to
|
|
33
|
+
self.field_name = field
|
|
34
|
+
self.name: str | None = None
|
|
35
|
+
self.model: type[Model] | None = None
|
|
36
|
+
self._resolved_model: type[T] | None = None
|
|
37
|
+
self._resolved_field: Any = None
|
|
38
|
+
|
|
39
|
+
def contribute_to_class(self, cls: type[Model], name: str) -> None:
|
|
40
|
+
"""
|
|
41
|
+
Register this reverse relation with the model class.
|
|
42
|
+
|
|
43
|
+
Called by the model metaclass when the model is created.
|
|
44
|
+
"""
|
|
45
|
+
self.name = name
|
|
46
|
+
self.model = cls
|
|
47
|
+
|
|
48
|
+
# Set the descriptor on the class
|
|
49
|
+
setattr(cls, name, self)
|
|
50
|
+
|
|
51
|
+
# Register this as a related object for prefetch support
|
|
52
|
+
# We'll do this lazily when the target model is resolved
|
|
53
|
+
from plain.postgres.fields.related import lazy_related_operation
|
|
54
|
+
|
|
55
|
+
def resolve_related_field(
|
|
56
|
+
parent_model: type[Model], related_model: type[T]
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Resolve the target model and field, then register."""
|
|
59
|
+
self._resolved_model = related_model
|
|
60
|
+
try:
|
|
61
|
+
self._resolved_field = related_model._model_meta.get_field(
|
|
62
|
+
self.field_name
|
|
63
|
+
)
|
|
64
|
+
except Exception as e:
|
|
65
|
+
raise ValueError(
|
|
66
|
+
f"Field '{self.field_name}' not found on model "
|
|
67
|
+
f"'{related_model.__name__}' for {self._get_descriptor_type()} '{self.name}' "
|
|
68
|
+
f"on '{cls.__name__}'. Error: {e}"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Validate that the field is the correct type
|
|
72
|
+
self._validate_field_type(related_model)
|
|
73
|
+
|
|
74
|
+
# Use lazy operation to handle circular dependencies
|
|
75
|
+
lazy_related_operation(resolve_related_field, cls, self.to)
|
|
76
|
+
|
|
77
|
+
def __get__(
|
|
78
|
+
self, instance: Model | None, owner: type[Model]
|
|
79
|
+
) -> BaseReverseDescriptor[T, QS] | BaseRelatedManager[T, QS]:
|
|
80
|
+
"""
|
|
81
|
+
Get the related manager when accessed on an instance.
|
|
82
|
+
|
|
83
|
+
When accessed on the class, returns the descriptor.
|
|
84
|
+
When accessed on an instance, returns a manager.
|
|
85
|
+
"""
|
|
86
|
+
if instance is None:
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
# Ensure the related model and field are resolved
|
|
90
|
+
if self._resolved_field is None or self.model is None:
|
|
91
|
+
model_name = self.model.__name__ if self.model else "Unknown"
|
|
92
|
+
raise ValueError(
|
|
93
|
+
f"{self._get_descriptor_type()} '{self.name}' on '{model_name}' "
|
|
94
|
+
f"has not been resolved yet. The target model may not be registered."
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# _resolved_model is set alongside _resolved_field in resolve_related_field
|
|
98
|
+
assert self._resolved_model is not None, "Model should be resolved with field"
|
|
99
|
+
|
|
100
|
+
# Return a manager bound to this instance
|
|
101
|
+
return self._create_manager(instance)
|
|
102
|
+
|
|
103
|
+
def __set__(self, instance: Model, value: Any) -> None:
|
|
104
|
+
"""Prevent direct assignment to reverse relations."""
|
|
105
|
+
raise TypeError(
|
|
106
|
+
f"Direct assignment to the reverse side of a {self._get_field_type()} "
|
|
107
|
+
f"('{self.name}') is prohibited. Use {self.name}.set() instead."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def _get_descriptor_type(self) -> str:
|
|
111
|
+
"""Return the name of this descriptor type for error messages."""
|
|
112
|
+
raise NotImplementedError("Subclasses must implement _get_descriptor_type()")
|
|
113
|
+
|
|
114
|
+
def _get_field_type(self) -> str:
|
|
115
|
+
"""Return the name of the forward field type for error messages."""
|
|
116
|
+
raise NotImplementedError("Subclasses must implement _get_field_type()")
|
|
117
|
+
|
|
118
|
+
def _validate_field_type(self, related_model: type[Model]) -> None:
|
|
119
|
+
"""Validate that the resolved field is the correct type."""
|
|
120
|
+
raise NotImplementedError("Subclasses must implement _validate_field_type()")
|
|
121
|
+
|
|
122
|
+
def _create_manager(self, instance: Model) -> Any:
|
|
123
|
+
"""Create and return the appropriate manager for this instance."""
|
|
124
|
+
raise NotImplementedError("Subclasses must implement _create_manager()")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class ReverseForeignKey(BaseReverseDescriptor[T, QS]):
|
|
128
|
+
"""
|
|
129
|
+
Descriptor for the reverse side of a ForeignKeyField relation.
|
|
130
|
+
|
|
131
|
+
Provides access to the related instances on the "one" side of a one-to-many
|
|
132
|
+
relationship.
|
|
133
|
+
|
|
134
|
+
Example:
|
|
135
|
+
class Parent(Model):
|
|
136
|
+
# Basic usage (uses default QuerySet[Child])
|
|
137
|
+
children: ReverseForeignKey[Child, QuerySet[Child]] = ReverseForeignKey(to="Child", field="parent")
|
|
138
|
+
|
|
139
|
+
# With custom QuerySet
|
|
140
|
+
children: ReverseForeignKey[Child, ChildQuerySet] = ReverseForeignKey(to="Child", field="parent")
|
|
141
|
+
|
|
142
|
+
class Child(Model):
|
|
143
|
+
parent: Parent = ForeignKeyField(Parent, on_delete=models.CASCADE)
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
to: The related model (string name or model class)
|
|
147
|
+
field: The field name on the related model that points back to this model
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
def _get_descriptor_type(self) -> str:
|
|
151
|
+
return "ReverseForeignKey"
|
|
152
|
+
|
|
153
|
+
def _get_field_type(self) -> str:
|
|
154
|
+
return "ForeignKey"
|
|
155
|
+
|
|
156
|
+
def _validate_field_type(self, related_model: type[Model]) -> None:
|
|
157
|
+
"""Validate that the field is a ForeignKey."""
|
|
158
|
+
from plain.postgres.fields.related import ForeignKeyField
|
|
159
|
+
|
|
160
|
+
if not isinstance(self._resolved_field, ForeignKeyField):
|
|
161
|
+
raise ValueError(
|
|
162
|
+
f"Field '{self.field_name}' on '{related_model.__name__}' is not a "
|
|
163
|
+
f"ForeignKey. ReverseForeignKey requires a ForeignKeyField field."
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def _create_manager(self, instance: Model) -> Any:
|
|
167
|
+
"""Create a ReverseForeignKeyManager for this instance."""
|
|
168
|
+
from plain.postgres.fields.related_managers import ReverseForeignKeyManager
|
|
169
|
+
|
|
170
|
+
assert self._resolved_model is not None
|
|
171
|
+
return ReverseForeignKeyManager(
|
|
172
|
+
instance=instance,
|
|
173
|
+
field=self._resolved_field,
|
|
174
|
+
related_model=self._resolved_model,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class ReverseManyToMany(BaseReverseDescriptor[T, QS]):
|
|
179
|
+
"""
|
|
180
|
+
Descriptor for the reverse side of a ManyToManyField relation.
|
|
181
|
+
|
|
182
|
+
Provides access to the related instances on the reverse side of a many-to-many
|
|
183
|
+
relationship.
|
|
184
|
+
|
|
185
|
+
Example:
|
|
186
|
+
class Feature(Model):
|
|
187
|
+
# Basic usage (uses default QuerySet[Car])
|
|
188
|
+
cars: ReverseManyToMany[Car, QuerySet[Car]] = ReverseManyToMany(to="Car", field="features")
|
|
189
|
+
|
|
190
|
+
# With custom QuerySet
|
|
191
|
+
cars: ReverseManyToMany[Car, CarQuerySet] = ReverseManyToMany(to="Car", field="features")
|
|
192
|
+
|
|
193
|
+
class Car(Model):
|
|
194
|
+
features: ManyToManyField[Feature] = ManyToManyField(Feature, through=CarFeature)
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
to: The related model (string name or model class)
|
|
198
|
+
field: The field name on the related model that points to this model
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
def _get_descriptor_type(self) -> str:
|
|
202
|
+
return "ReverseManyToMany"
|
|
203
|
+
|
|
204
|
+
def _get_field_type(self) -> str:
|
|
205
|
+
return "ManyToManyField"
|
|
206
|
+
|
|
207
|
+
def _validate_field_type(self, related_model: type[Model]) -> None:
|
|
208
|
+
"""Validate that the field is a ManyToManyField."""
|
|
209
|
+
from plain.postgres.fields.related import ManyToManyField
|
|
210
|
+
|
|
211
|
+
if not isinstance(self._resolved_field, ManyToManyField):
|
|
212
|
+
raise ValueError(
|
|
213
|
+
f"Field '{self.field_name}' on '{related_model.__name__}' is not a "
|
|
214
|
+
f"ManyToManyField. ReverseManyToMany requires a ManyToManyField."
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def _create_manager(self, instance: Model) -> Any:
|
|
218
|
+
"""Create a ManyToManyManager for this instance."""
|
|
219
|
+
from plain.postgres.fields.related_managers import ManyToManyManager
|
|
220
|
+
|
|
221
|
+
assert self._resolved_model is not None
|
|
222
|
+
return ManyToManyManager(
|
|
223
|
+
instance=instance,
|
|
224
|
+
field=self._resolved_field,
|
|
225
|
+
through=self._resolved_field.remote_field.through,
|
|
226
|
+
related_model=self._resolved_model,
|
|
227
|
+
is_reverse=True,
|
|
228
|
+
symmetrical=False,
|
|
229
|
+
)
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""
|
|
2
|
+
"Rel objects" for related fields.
|
|
3
|
+
|
|
4
|
+
"Rel objects" (for lack of a better name) carry information about the relation
|
|
5
|
+
modeled by a related field and provide some utility functions. They're stored
|
|
6
|
+
in the ``remote_field`` attribute of the field.
|
|
7
|
+
|
|
8
|
+
They also act as reverse fields for the purposes of the Meta API because
|
|
9
|
+
they're the closest concept currently available.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from functools import cached_property
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
from plain.postgres.exceptions import FieldError
|
|
18
|
+
from plain.utils.hashable import make_hashable
|
|
19
|
+
|
|
20
|
+
from . import BLANK_CHOICE_DASH
|
|
21
|
+
from .mixins import FieldCacheMixin
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
|
|
26
|
+
from plain.postgres.base import Model
|
|
27
|
+
from plain.postgres.deletion import Collector
|
|
28
|
+
from plain.postgres.fields import Field
|
|
29
|
+
from plain.postgres.fields.related import (
|
|
30
|
+
ForeignKeyField,
|
|
31
|
+
ManyToManyField,
|
|
32
|
+
RelatedField,
|
|
33
|
+
)
|
|
34
|
+
from plain.postgres.lookups import Lookup
|
|
35
|
+
from plain.postgres.query_utils import PathInfo, Q
|
|
36
|
+
|
|
37
|
+
# Type alias for on_delete callbacks
|
|
38
|
+
OnDeleteCallback = Callable[[Collector, Any, Any], None]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ForeignObjectRel(FieldCacheMixin):
|
|
42
|
+
"""
|
|
43
|
+
Used by ForeignKeyField to store information about the relation.
|
|
44
|
+
|
|
45
|
+
``_model_meta.get_fields()`` returns this class to provide access to the field
|
|
46
|
+
flags for the reverse relation.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
# Field flags
|
|
50
|
+
auto_created = True
|
|
51
|
+
concrete = False
|
|
52
|
+
|
|
53
|
+
# Reverse relations are always nullable (Plain can't enforce that a
|
|
54
|
+
# foreign key on the related model points to this model).
|
|
55
|
+
allow_null = True
|
|
56
|
+
empty_strings_allowed = False
|
|
57
|
+
|
|
58
|
+
# Type annotations for instance attributes
|
|
59
|
+
model: type[Model]
|
|
60
|
+
field: RelatedField
|
|
61
|
+
on_delete: OnDeleteCallback | None
|
|
62
|
+
limit_choices_to: dict[str, Any] | Q
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
field: RelatedField,
|
|
67
|
+
to: str | type[Model],
|
|
68
|
+
related_query_name: str | None = None,
|
|
69
|
+
limit_choices_to: dict[str, Any] | Q | None = None,
|
|
70
|
+
on_delete: OnDeleteCallback | None = None,
|
|
71
|
+
):
|
|
72
|
+
self.field = field # type: ignore[misc]
|
|
73
|
+
# Initially may be a string, gets resolved to type[Model] by lazy_related_operation
|
|
74
|
+
# (see related.py:250 where field.remote_field.model is overwritten)
|
|
75
|
+
self.model = to # type: ignore[assignment]
|
|
76
|
+
self.related_query_name = related_query_name
|
|
77
|
+
self.limit_choices_to = {} if limit_choices_to is None else limit_choices_to
|
|
78
|
+
self.on_delete = on_delete
|
|
79
|
+
|
|
80
|
+
self.symmetrical = False
|
|
81
|
+
self.multiple = True
|
|
82
|
+
|
|
83
|
+
# Some of the following cached_properties can't be initialized in
|
|
84
|
+
# __init__ as the field doesn't have its model yet. Calling these methods
|
|
85
|
+
# before field.contribute_to_class() has been called will result in
|
|
86
|
+
# AttributeError
|
|
87
|
+
@cached_property
|
|
88
|
+
def name(self) -> str:
|
|
89
|
+
return self.field.related_query_name()
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def remote_field(self) -> RelatedField:
|
|
93
|
+
return self.field
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def target_field(self) -> Field:
|
|
97
|
+
"""
|
|
98
|
+
When filtering against this relation, return the field on the remote
|
|
99
|
+
model against which the filtering should happen.
|
|
100
|
+
"""
|
|
101
|
+
target_fields = self.path_infos[-1].target_fields
|
|
102
|
+
if len(target_fields) > 1:
|
|
103
|
+
raise FieldError("Can't use target_field for multicolumn relations.")
|
|
104
|
+
return target_fields[0]
|
|
105
|
+
|
|
106
|
+
@cached_property
|
|
107
|
+
def related_model(self) -> type[Model]:
|
|
108
|
+
if not self.field.model:
|
|
109
|
+
raise AttributeError(
|
|
110
|
+
"This property can't be accessed before self.field.contribute_to_class "
|
|
111
|
+
"has been called."
|
|
112
|
+
)
|
|
113
|
+
return self.field.model
|
|
114
|
+
|
|
115
|
+
def get_lookup(self, lookup_name: str) -> type[Lookup] | None:
|
|
116
|
+
return self.field.get_lookup(lookup_name)
|
|
117
|
+
|
|
118
|
+
def get_internal_type(self) -> str:
|
|
119
|
+
return self.field.get_internal_type()
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def db_type(self) -> str | None:
|
|
123
|
+
return self.field.db_type
|
|
124
|
+
|
|
125
|
+
def __repr__(self) -> str:
|
|
126
|
+
return f"<{type(self).__name__}: {self.related_model.model_options.package_label}.{self.related_model.model_options.model_name}>"
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def identity(self) -> tuple[Any, ...]:
|
|
130
|
+
return (
|
|
131
|
+
self.field,
|
|
132
|
+
self.model,
|
|
133
|
+
self.related_query_name,
|
|
134
|
+
make_hashable(self.limit_choices_to),
|
|
135
|
+
self.on_delete,
|
|
136
|
+
self.symmetrical,
|
|
137
|
+
self.multiple,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def __eq__(self, other: object) -> bool:
|
|
141
|
+
if not isinstance(other, self.__class__):
|
|
142
|
+
return NotImplemented
|
|
143
|
+
return self.identity == other.identity
|
|
144
|
+
|
|
145
|
+
def __hash__(self) -> int:
|
|
146
|
+
return hash(self.identity)
|
|
147
|
+
|
|
148
|
+
def __getstate__(self) -> dict[str, Any]:
|
|
149
|
+
state = self.__dict__.copy()
|
|
150
|
+
# Delete the path_infos cached property because it can be recalculated
|
|
151
|
+
# at first invocation after deserialization. The attribute must be
|
|
152
|
+
# removed because subclasses like ForeignKeyRel may have a PathInfo
|
|
153
|
+
# which contains an intermediate M2M table that's been dynamically
|
|
154
|
+
# created and doesn't exist in the .models module.
|
|
155
|
+
# This is a reverse relation, so there is no reverse_path_infos to
|
|
156
|
+
# delete.
|
|
157
|
+
state.pop("path_infos", None)
|
|
158
|
+
return state
|
|
159
|
+
|
|
160
|
+
def get_choices(
|
|
161
|
+
self,
|
|
162
|
+
include_blank: bool = True,
|
|
163
|
+
blank_choice: list[tuple[str, str]] = BLANK_CHOICE_DASH,
|
|
164
|
+
limit_choices_to: Any = None,
|
|
165
|
+
ordering: tuple[str, ...] = (),
|
|
166
|
+
) -> list[tuple[Any, str]]:
|
|
167
|
+
"""
|
|
168
|
+
Return choices with a default blank choices included, for use
|
|
169
|
+
as <select> choices for this field.
|
|
170
|
+
|
|
171
|
+
Analog of plain.postgres.fields.Field.get_choices(), provided
|
|
172
|
+
initially for utilization by RelatedFieldListFilter.
|
|
173
|
+
"""
|
|
174
|
+
limit_choices_to = limit_choices_to or self.limit_choices_to
|
|
175
|
+
qs = self.related_model.query.complex_filter(limit_choices_to)
|
|
176
|
+
if ordering:
|
|
177
|
+
qs = qs.order_by(*ordering)
|
|
178
|
+
return (blank_choice if include_blank else []) + [(x.id, str(x)) for x in qs]
|
|
179
|
+
|
|
180
|
+
def get_joining_columns(self) -> tuple[tuple[str, str], ...]:
|
|
181
|
+
return self.field.get_reverse_joining_columns()
|
|
182
|
+
|
|
183
|
+
def set_field_name(self) -> None:
|
|
184
|
+
"""
|
|
185
|
+
Set the related field's name, this is not available until later stages
|
|
186
|
+
of app loading, so set_field_name is called from
|
|
187
|
+
set_attributes_from_rel()
|
|
188
|
+
"""
|
|
189
|
+
# By default foreign object doesn't relate to any remote field (for
|
|
190
|
+
# example custom multicolumn joins currently have no remote field).
|
|
191
|
+
self.field_name = None
|
|
192
|
+
|
|
193
|
+
def get_path_info(self, filtered_relation: Any = None) -> list[PathInfo]:
|
|
194
|
+
if filtered_relation:
|
|
195
|
+
return self.field.get_reverse_path_info(filtered_relation)
|
|
196
|
+
else:
|
|
197
|
+
return self.field.reverse_path_infos
|
|
198
|
+
|
|
199
|
+
@cached_property
|
|
200
|
+
def path_infos(self) -> list[PathInfo]:
|
|
201
|
+
return self.get_path_info()
|
|
202
|
+
|
|
203
|
+
def get_cache_name(self) -> str:
|
|
204
|
+
"""
|
|
205
|
+
Return the name of the cache key to use for storing an instance of the
|
|
206
|
+
forward model on the reverse model.
|
|
207
|
+
|
|
208
|
+
Uses the related_query_name for caching, which provides a stable name
|
|
209
|
+
for prefetch_related operations.
|
|
210
|
+
"""
|
|
211
|
+
return self.field.related_query_name()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class ForeignKeyRel(ForeignObjectRel):
|
|
215
|
+
"""
|
|
216
|
+
Used by the ForeignKeyField field to store information about the relation.
|
|
217
|
+
|
|
218
|
+
``_model_meta.get_fields()`` returns this class to provide access to the
|
|
219
|
+
reverse relation. Use ``isinstance(rel, ForeignKeyRel)`` to identify
|
|
220
|
+
one-to-many reverse relations.
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
# Type annotations for instance attributes
|
|
224
|
+
field: ForeignKeyField
|
|
225
|
+
|
|
226
|
+
def __init__(
|
|
227
|
+
self,
|
|
228
|
+
field: ForeignKeyField,
|
|
229
|
+
to: str | type[Model],
|
|
230
|
+
related_query_name: str | None = None,
|
|
231
|
+
limit_choices_to: dict[str, Any] | Q | None = None,
|
|
232
|
+
on_delete: OnDeleteCallback | None = None,
|
|
233
|
+
):
|
|
234
|
+
super().__init__(
|
|
235
|
+
field,
|
|
236
|
+
to,
|
|
237
|
+
related_query_name=related_query_name,
|
|
238
|
+
limit_choices_to=limit_choices_to,
|
|
239
|
+
on_delete=on_delete,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
self.field_name = "id"
|
|
243
|
+
|
|
244
|
+
def __getstate__(self) -> dict[str, Any]:
|
|
245
|
+
state = super().__getstate__()
|
|
246
|
+
state.pop("related_model", None)
|
|
247
|
+
return state
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def identity(self) -> tuple[Any, ...]:
|
|
251
|
+
return super().identity + (self.field_name,)
|
|
252
|
+
|
|
253
|
+
def get_related_field(self) -> Field:
|
|
254
|
+
"""
|
|
255
|
+
Return the Field in the 'to' object to which this relationship is tied.
|
|
256
|
+
"""
|
|
257
|
+
return self.model._model_meta.get_forward_field("id")
|
|
258
|
+
|
|
259
|
+
def set_field_name(self) -> None:
|
|
260
|
+
pass
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class ManyToManyRel(ForeignObjectRel):
|
|
264
|
+
"""
|
|
265
|
+
Used by ManyToManyField to store information about the relation.
|
|
266
|
+
|
|
267
|
+
``_model_meta.get_fields()`` returns this class to provide access to the field
|
|
268
|
+
flags for the reverse relation.
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
# Type annotations for instance attributes
|
|
272
|
+
field: ManyToManyField
|
|
273
|
+
through: type[Model]
|
|
274
|
+
through_fields: tuple[str, str] | None
|
|
275
|
+
|
|
276
|
+
def __init__(
|
|
277
|
+
self,
|
|
278
|
+
field: ManyToManyField,
|
|
279
|
+
to: str | type[Model],
|
|
280
|
+
*,
|
|
281
|
+
through: str | type[Model],
|
|
282
|
+
through_fields: tuple[str, str] | None = None,
|
|
283
|
+
related_query_name: str | None = None,
|
|
284
|
+
limit_choices_to: dict[str, Any] | Q | None = None,
|
|
285
|
+
symmetrical: bool = True,
|
|
286
|
+
):
|
|
287
|
+
super().__init__(
|
|
288
|
+
field,
|
|
289
|
+
to,
|
|
290
|
+
related_query_name=related_query_name,
|
|
291
|
+
limit_choices_to=limit_choices_to,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
# Initially may be a string, gets resolved to type[Model] by lazy_related_operation
|
|
295
|
+
# (see related.py:1143 where field.remote_field.through is overwritten)
|
|
296
|
+
self.through = through # type: ignore[assignment]
|
|
297
|
+
self.through_fields = through_fields
|
|
298
|
+
|
|
299
|
+
self.symmetrical = symmetrical
|
|
300
|
+
self.db_constraint = True
|
|
301
|
+
|
|
302
|
+
@property
|
|
303
|
+
def identity(self) -> tuple[Any, ...]:
|
|
304
|
+
return super().identity + (
|
|
305
|
+
self.through,
|
|
306
|
+
make_hashable(self.through_fields),
|
|
307
|
+
self.db_constraint,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
def get_related_field(self) -> Field:
|
|
311
|
+
"""
|
|
312
|
+
Return the field in the 'to' object to which this relationship is tied.
|
|
313
|
+
Provided for symmetry with ForeignKeyRel.
|
|
314
|
+
"""
|
|
315
|
+
from plain.postgres.fields.related import ForeignKeyField
|
|
316
|
+
|
|
317
|
+
meta = self.through._model_meta
|
|
318
|
+
if self.through_fields:
|
|
319
|
+
field = meta.get_forward_field(self.through_fields[0])
|
|
320
|
+
else:
|
|
321
|
+
for field in meta.fields:
|
|
322
|
+
rel = getattr(field, "remote_field", None)
|
|
323
|
+
if rel and rel.model == self.model:
|
|
324
|
+
break
|
|
325
|
+
|
|
326
|
+
if not isinstance(field, ForeignKeyField):
|
|
327
|
+
raise ValueError(f"Expected ForeignKeyField, got {type(field)}")
|
|
328
|
+
return field.foreign_related_fields[0]
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import zoneinfo
|
|
4
|
+
from functools import cache
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from plain import exceptions
|
|
8
|
+
|
|
9
|
+
from . import Field
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from plain.postgres.base import Model
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@cache
|
|
16
|
+
def _get_canonical_timezones() -> frozenset[str]:
|
|
17
|
+
"""
|
|
18
|
+
Get canonical IANA timezone names, excluding deprecated legacy aliases.
|
|
19
|
+
|
|
20
|
+
Filters out legacy timezone names like US/Central, Canada/Eastern, etc.
|
|
21
|
+
that are backward compatibility aliases. These legacy names can cause
|
|
22
|
+
issues with databases like PostgreSQL that only recognize canonical names.
|
|
23
|
+
"""
|
|
24
|
+
all_zones = zoneinfo.available_timezones()
|
|
25
|
+
|
|
26
|
+
# Known legacy prefixes (deprecated in favor of Area/Location format)
|
|
27
|
+
legacy_prefixes = ("US/", "Canada/", "Brazil/", "Chile/", "Mexico/")
|
|
28
|
+
|
|
29
|
+
# Obsolete timezone abbreviations
|
|
30
|
+
obsolete_zones = {
|
|
31
|
+
"EST",
|
|
32
|
+
"MST",
|
|
33
|
+
"HST",
|
|
34
|
+
"EST5EDT",
|
|
35
|
+
"CST6CDT",
|
|
36
|
+
"MST7MDT",
|
|
37
|
+
"PST8PDT",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# Filter to only canonical timezone names
|
|
41
|
+
return frozenset(
|
|
42
|
+
tz
|
|
43
|
+
for tz in all_zones
|
|
44
|
+
if not tz.startswith(legacy_prefixes) and tz not in obsolete_zones
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TimeZoneField(Field[zoneinfo.ZoneInfo]):
|
|
49
|
+
"""
|
|
50
|
+
A model field that stores timezone names as strings but provides ZoneInfo objects.
|
|
51
|
+
|
|
52
|
+
Similar to DateField which stores dates but provides datetime.date objects,
|
|
53
|
+
this field stores timezone strings (e.g., "America/Chicago") but provides
|
|
54
|
+
zoneinfo.ZoneInfo objects when accessed.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
description = "A timezone (stored as string, accessed as ZoneInfo)"
|
|
58
|
+
|
|
59
|
+
# Mapping of legacy timezone names to canonical IANA names
|
|
60
|
+
# Based on IANA timezone database backward compatibility file
|
|
61
|
+
LEGACY_TO_CANONICAL = {
|
|
62
|
+
"US/Alaska": "America/Anchorage",
|
|
63
|
+
"US/Aleutian": "America/Adak",
|
|
64
|
+
"US/Arizona": "America/Phoenix",
|
|
65
|
+
"US/Central": "America/Chicago",
|
|
66
|
+
"US/East-Indiana": "America/Indiana/Indianapolis",
|
|
67
|
+
"US/Eastern": "America/New_York",
|
|
68
|
+
"US/Hawaii": "Pacific/Honolulu",
|
|
69
|
+
"US/Indiana-Starke": "America/Indiana/Knox",
|
|
70
|
+
"US/Michigan": "America/Detroit",
|
|
71
|
+
"US/Mountain": "America/Denver",
|
|
72
|
+
"US/Pacific": "America/Los_Angeles",
|
|
73
|
+
"US/Samoa": "Pacific/Pago_Pago",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
def __init__(self, **kwargs: Any):
|
|
77
|
+
if "choices" in kwargs:
|
|
78
|
+
raise TypeError("TimeZoneField does not accept custom choices.")
|
|
79
|
+
kwargs.setdefault("max_length", 100)
|
|
80
|
+
kwargs["choices"] = self._get_timezone_choices()
|
|
81
|
+
super().__init__(**kwargs)
|
|
82
|
+
|
|
83
|
+
def deconstruct(self) -> tuple[str | None, str, list[Any], dict[str, Any]]:
|
|
84
|
+
name, path, args, kwargs = super().deconstruct()
|
|
85
|
+
# Don't serialize choices - they're computed dynamically from system tzdata
|
|
86
|
+
kwargs.pop("choices", None)
|
|
87
|
+
return name, path, args, kwargs
|
|
88
|
+
|
|
89
|
+
def _get_timezone_choices(self) -> list[tuple[str, str]]:
|
|
90
|
+
"""Get timezone choices for form widgets."""
|
|
91
|
+
zones = [(tz, tz) for tz in _get_canonical_timezones()]
|
|
92
|
+
zones.sort(key=lambda x: x[1])
|
|
93
|
+
return [("", "---------")] + zones
|
|
94
|
+
|
|
95
|
+
def get_internal_type(self) -> str:
|
|
96
|
+
return "CharField"
|
|
97
|
+
|
|
98
|
+
def to_python(self, value: Any) -> zoneinfo.ZoneInfo | None:
|
|
99
|
+
"""Convert input to ZoneInfo object."""
|
|
100
|
+
if value is None or value == "":
|
|
101
|
+
return None
|
|
102
|
+
if isinstance(value, zoneinfo.ZoneInfo):
|
|
103
|
+
return value
|
|
104
|
+
try:
|
|
105
|
+
return zoneinfo.ZoneInfo(value)
|
|
106
|
+
except zoneinfo.ZoneInfoNotFoundError:
|
|
107
|
+
raise exceptions.ValidationError(
|
|
108
|
+
f"'{value}' is not a valid timezone.",
|
|
109
|
+
code="invalid",
|
|
110
|
+
params={"value": value},
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def from_db_value(
|
|
114
|
+
self, value: Any, expression: Any, connection: Any
|
|
115
|
+
) -> zoneinfo.ZoneInfo | None:
|
|
116
|
+
"""Convert database value to ZoneInfo object."""
|
|
117
|
+
if value is None or value == "":
|
|
118
|
+
return None
|
|
119
|
+
# Normalize legacy timezone names
|
|
120
|
+
value = self.LEGACY_TO_CANONICAL.get(value, value)
|
|
121
|
+
return zoneinfo.ZoneInfo(value)
|
|
122
|
+
|
|
123
|
+
def get_prep_value(self, value: Any) -> str | None:
|
|
124
|
+
"""Convert ZoneInfo to string for database storage."""
|
|
125
|
+
if value is None:
|
|
126
|
+
return None
|
|
127
|
+
if isinstance(value, zoneinfo.ZoneInfo):
|
|
128
|
+
value = str(value)
|
|
129
|
+
# Normalize legacy timezone names before saving
|
|
130
|
+
return self.LEGACY_TO_CANONICAL.get(value, value)
|
|
131
|
+
|
|
132
|
+
def value_to_string(self, obj: Model) -> str:
|
|
133
|
+
"""Serialize value for fixtures/migrations."""
|
|
134
|
+
value = self.value_from_object(obj)
|
|
135
|
+
prep_value = self.get_prep_value(value)
|
|
136
|
+
return prep_value if prep_value is not None else ""
|
|
137
|
+
|
|
138
|
+
def validate(self, value: Any, model_instance: Model) -> None:
|
|
139
|
+
"""Validate value against choices using string comparison."""
|
|
140
|
+
# Convert ZoneInfo to string for choice validation since choices are strings
|
|
141
|
+
if isinstance(value, zoneinfo.ZoneInfo):
|
|
142
|
+
value = str(value)
|
|
143
|
+
return super().validate(value, model_instance)
|