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.
Files changed (93) hide show
  1. plain/postgres/CHANGELOG.md +1028 -0
  2. plain/postgres/README.md +925 -0
  3. plain/postgres/__init__.py +120 -0
  4. plain/postgres/agents/.claude/rules/plain-postgres.md +78 -0
  5. plain/postgres/aggregates.py +236 -0
  6. plain/postgres/backups/__init__.py +0 -0
  7. plain/postgres/backups/cli.py +148 -0
  8. plain/postgres/backups/clients.py +94 -0
  9. plain/postgres/backups/core.py +172 -0
  10. plain/postgres/base.py +1415 -0
  11. plain/postgres/cli/__init__.py +3 -0
  12. plain/postgres/cli/db.py +142 -0
  13. plain/postgres/cli/migrations.py +1085 -0
  14. plain/postgres/config.py +18 -0
  15. plain/postgres/connection.py +1331 -0
  16. plain/postgres/connections.py +77 -0
  17. plain/postgres/constants.py +13 -0
  18. plain/postgres/constraints.py +495 -0
  19. plain/postgres/database_url.py +94 -0
  20. plain/postgres/db.py +59 -0
  21. plain/postgres/default_settings.py +38 -0
  22. plain/postgres/deletion.py +475 -0
  23. plain/postgres/dialect.py +640 -0
  24. plain/postgres/entrypoints.py +4 -0
  25. plain/postgres/enums.py +103 -0
  26. plain/postgres/exceptions.py +217 -0
  27. plain/postgres/expressions.py +1912 -0
  28. plain/postgres/fields/__init__.py +2118 -0
  29. plain/postgres/fields/encrypted.py +354 -0
  30. plain/postgres/fields/json.py +413 -0
  31. plain/postgres/fields/mixins.py +30 -0
  32. plain/postgres/fields/related.py +1192 -0
  33. plain/postgres/fields/related_descriptors.py +290 -0
  34. plain/postgres/fields/related_lookups.py +223 -0
  35. plain/postgres/fields/related_managers.py +661 -0
  36. plain/postgres/fields/reverse_descriptors.py +229 -0
  37. plain/postgres/fields/reverse_related.py +328 -0
  38. plain/postgres/fields/timezones.py +143 -0
  39. plain/postgres/forms.py +773 -0
  40. plain/postgres/functions/__init__.py +189 -0
  41. plain/postgres/functions/comparison.py +127 -0
  42. plain/postgres/functions/datetime.py +454 -0
  43. plain/postgres/functions/math.py +140 -0
  44. plain/postgres/functions/mixins.py +59 -0
  45. plain/postgres/functions/text.py +282 -0
  46. plain/postgres/functions/window.py +125 -0
  47. plain/postgres/indexes.py +286 -0
  48. plain/postgres/lookups.py +758 -0
  49. plain/postgres/meta.py +584 -0
  50. plain/postgres/migrations/__init__.py +53 -0
  51. plain/postgres/migrations/autodetector.py +1379 -0
  52. plain/postgres/migrations/exceptions.py +54 -0
  53. plain/postgres/migrations/executor.py +188 -0
  54. plain/postgres/migrations/graph.py +364 -0
  55. plain/postgres/migrations/loader.py +377 -0
  56. plain/postgres/migrations/migration.py +180 -0
  57. plain/postgres/migrations/operations/__init__.py +34 -0
  58. plain/postgres/migrations/operations/base.py +139 -0
  59. plain/postgres/migrations/operations/fields.py +373 -0
  60. plain/postgres/migrations/operations/models.py +798 -0
  61. plain/postgres/migrations/operations/special.py +184 -0
  62. plain/postgres/migrations/optimizer.py +74 -0
  63. plain/postgres/migrations/questioner.py +340 -0
  64. plain/postgres/migrations/recorder.py +119 -0
  65. plain/postgres/migrations/serializer.py +378 -0
  66. plain/postgres/migrations/state.py +882 -0
  67. plain/postgres/migrations/utils.py +147 -0
  68. plain/postgres/migrations/writer.py +302 -0
  69. plain/postgres/options.py +207 -0
  70. plain/postgres/otel.py +231 -0
  71. plain/postgres/preflight.py +336 -0
  72. plain/postgres/query.py +2242 -0
  73. plain/postgres/query_utils.py +456 -0
  74. plain/postgres/registry.py +217 -0
  75. plain/postgres/schema.py +1885 -0
  76. plain/postgres/sql/__init__.py +40 -0
  77. plain/postgres/sql/compiler.py +1869 -0
  78. plain/postgres/sql/constants.py +22 -0
  79. plain/postgres/sql/datastructures.py +222 -0
  80. plain/postgres/sql/query.py +2947 -0
  81. plain/postgres/sql/where.py +374 -0
  82. plain/postgres/test/__init__.py +0 -0
  83. plain/postgres/test/pytest.py +117 -0
  84. plain/postgres/test/utils.py +18 -0
  85. plain/postgres/transaction.py +222 -0
  86. plain/postgres/types.py +92 -0
  87. plain/postgres/types.pyi +751 -0
  88. plain/postgres/utils.py +345 -0
  89. plain_postgres-0.84.0.dist-info/METADATA +937 -0
  90. plain_postgres-0.84.0.dist-info/RECORD +93 -0
  91. plain_postgres-0.84.0.dist-info/WHEEL +4 -0
  92. plain_postgres-0.84.0.dist-info/entry_points.txt +5 -0
  93. 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)