plain.models 0.50.0__py3-none-any.whl → 0.51.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 (49) hide show
  1. plain/models/CHANGELOG.md +14 -0
  2. plain/models/README.md +26 -42
  3. plain/models/__init__.py +2 -0
  4. plain/models/backends/base/creation.py +2 -2
  5. plain/models/backends/base/introspection.py +8 -4
  6. plain/models/backends/base/schema.py +89 -71
  7. plain/models/backends/base/validation.py +1 -1
  8. plain/models/backends/mysql/compiler.py +1 -1
  9. plain/models/backends/mysql/operations.py +1 -1
  10. plain/models/backends/mysql/schema.py +4 -4
  11. plain/models/backends/postgresql/operations.py +1 -1
  12. plain/models/backends/postgresql/schema.py +3 -3
  13. plain/models/backends/sqlite3/operations.py +1 -1
  14. plain/models/backends/sqlite3/schema.py +61 -50
  15. plain/models/base.py +116 -163
  16. plain/models/cli.py +4 -4
  17. plain/models/constraints.py +14 -9
  18. plain/models/deletion.py +15 -14
  19. plain/models/expressions.py +1 -1
  20. plain/models/fields/__init__.py +20 -16
  21. plain/models/fields/json.py +3 -3
  22. plain/models/fields/related.py +73 -71
  23. plain/models/fields/related_descriptors.py +2 -2
  24. plain/models/fields/related_lookups.py +1 -1
  25. plain/models/fields/related_managers.py +21 -32
  26. plain/models/fields/reverse_related.py +8 -8
  27. plain/models/forms.py +12 -12
  28. plain/models/indexes.py +5 -4
  29. plain/models/meta.py +505 -0
  30. plain/models/migrations/operations/base.py +1 -1
  31. plain/models/migrations/operations/fields.py +6 -6
  32. plain/models/migrations/operations/models.py +18 -16
  33. plain/models/migrations/recorder.py +9 -5
  34. plain/models/migrations/state.py +35 -46
  35. plain/models/migrations/utils.py +1 -1
  36. plain/models/options.py +182 -518
  37. plain/models/preflight.py +7 -5
  38. plain/models/query.py +119 -65
  39. plain/models/query_utils.py +18 -13
  40. plain/models/registry.py +6 -5
  41. plain/models/sql/compiler.py +51 -37
  42. plain/models/sql/query.py +77 -68
  43. plain/models/sql/subqueries.py +4 -4
  44. plain/models/utils.py +4 -1
  45. {plain_models-0.50.0.dist-info → plain_models-0.51.0.dist-info}/METADATA +27 -43
  46. {plain_models-0.50.0.dist-info → plain_models-0.51.0.dist-info}/RECORD +49 -48
  47. {plain_models-0.50.0.dist-info → plain_models-0.51.0.dist-info}/WHEEL +0 -0
  48. {plain_models-0.50.0.dist-info → plain_models-0.51.0.dist-info}/entry_points.txt +0 -0
  49. {plain_models-0.50.0.dist-info → plain_models-0.51.0.dist-info}/licenses/LICENSE +0 -0
plain/models/sql/query.py CHANGED
@@ -52,7 +52,7 @@ from plain.utils.tree import Node
52
52
  if TYPE_CHECKING:
53
53
  from plain.models import Model
54
54
  from plain.models.backends.base.base import BaseDatabaseWrapper
55
- from plain.models.options import Options
55
+ from plain.models.meta import Meta
56
56
  from plain.models.sql.compiler import SQLCompiler
57
57
 
58
58
 
@@ -67,12 +67,12 @@ FORBIDDEN_ALIAS_PATTERN = _lazy_re_compile(r"['`\"\]\[;\s]|--|/\*|\*/")
67
67
  EXPLAIN_OPTIONS_PATTERN = _lazy_re_compile(r"[\w\-]+")
68
68
 
69
69
 
70
- def get_field_names_from_opts(opts: Options | None) -> set[str]:
71
- if opts is None:
70
+ def get_field_names_from_opts(meta: Meta | None) -> set[str]:
71
+ if meta is None:
72
72
  return set()
73
73
  return set(
74
74
  chain.from_iterable(
75
- (f.name, f.attname) if f.concrete else (f.name,) for f in opts.get_fields()
75
+ (f.name, f.attname) if f.concrete else (f.name,) for f in meta.get_fields()
76
76
  )
77
77
  )
78
78
 
@@ -87,7 +87,7 @@ def get_children_from_q(q: Q) -> TypingIterator[tuple[str, Any]]:
87
87
 
88
88
  JoinInfo = namedtuple(
89
89
  "JoinInfo",
90
- ("final_field", "targets", "opts", "joins", "path", "transform_function"),
90
+ ("final_field", "targets", "meta", "joins", "path", "transform_function"),
91
91
  )
92
92
 
93
93
 
@@ -115,7 +115,10 @@ class RawQuery:
115
115
  if self.cursor is None:
116
116
  self._execute_query()
117
117
  converter = db_connection.introspection.identifier_converter
118
- return [converter(column_meta[0]) for column_meta in self.cursor.description]
118
+ return [
119
+ converter(column_model_meta[0])
120
+ for column_model_meta in self.cursor.description
121
+ ]
119
122
 
120
123
  def __iter__(self) -> TypingIterator[Any]:
121
124
  # Always execute a new query for a new iterator.
@@ -307,14 +310,13 @@ class Query(BaseExpression):
307
310
  self, db_connection, elide_empty
308
311
  )
309
312
 
310
- def get_meta(self) -> Options | None:
313
+ def get_model_meta(self) -> Meta:
311
314
  """
312
- Return the Options instance (the model._meta) from which to start
313
- processing. Normally, this is self.model._meta, but it can be changed
315
+ Return the Meta instance (the model._model_meta) from which to start
316
+ processing. Normally, this is self.model._model_meta, but it can be changed
314
317
  by subclasses.
315
318
  """
316
- if self.model:
317
- return self.model._meta # type: ignore[attr-defined,return-value]
319
+ return self.model._model_meta
318
320
 
319
321
  def clone(self) -> Query:
320
322
  """
@@ -449,7 +451,7 @@ class Query(BaseExpression):
449
451
  # used.
450
452
  if inner_query.default_cols and has_existing_aggregation:
451
453
  inner_query.group_by = (
452
- self.model._meta.get_field("id").get_col(
454
+ self.model._model_meta.get_field("id").get_col(
453
455
  inner_query.get_initial_alias()
454
456
  ),
455
457
  )
@@ -493,7 +495,7 @@ class Query(BaseExpression):
493
495
  # field selected in the inner query, yet we must use a subquery.
494
496
  # So, make sure at least one field is selected.
495
497
  inner_query.select = (
496
- self.model._meta.get_field("id").get_col(
498
+ self.model._model_meta.get_field("id").get_col(
497
499
  inner_query.get_initial_alias()
498
500
  ),
499
501
  )
@@ -552,7 +554,7 @@ class Query(BaseExpression):
552
554
  if not (q.distinct and q.is_sliced):
553
555
  if q.group_by is True:
554
556
  q.add_fields(
555
- (f.attname for f in self.model._meta.concrete_fields), False
557
+ (f.attname for f in self.model._model_meta.concrete_fields), False
556
558
  )
557
559
  # Disable GROUP BY aliases to avoid orphaning references to the
558
560
  # SELECT clause which is about to be cleared.
@@ -703,18 +705,18 @@ class Query(BaseExpression):
703
705
 
704
706
  def _get_defer_select_mask(
705
707
  self,
706
- opts: Options,
708
+ meta: Meta,
707
709
  mask: dict[str, Any],
708
710
  select_mask: dict[Any, Any] | None = None,
709
711
  ) -> dict[Any, Any]:
710
712
  if select_mask is None:
711
713
  select_mask = {}
712
- select_mask[opts.get_field("id")] = {}
714
+ select_mask[meta.get_field("id")] = {}
713
715
  # All concrete fields that are not part of the defer mask must be
714
716
  # loaded. If a relational field is encountered it gets added to the
715
717
  # mask for it be considered if `select_related` and the cycle continues
716
718
  # by recursively calling this function.
717
- for field in opts.concrete_fields:
719
+ for field in meta.concrete_fields:
718
720
  field_mask = mask.pop(field.name, None)
719
721
  if field_mask is None:
720
722
  select_mask.setdefault(field, {})
@@ -724,44 +726,44 @@ class Query(BaseExpression):
724
726
  field_select_mask = select_mask.setdefault(field, {})
725
727
  related_model = field.remote_field.model
726
728
  self._get_defer_select_mask(
727
- related_model._meta, field_mask, field_select_mask
729
+ related_model._model_meta, field_mask, field_select_mask
728
730
  )
729
731
  # Remaining defer entries must be references to reverse relationships.
730
732
  # The following code is expected to raise FieldError if it encounters
731
733
  # a malformed defer entry.
732
734
  for field_name, field_mask in mask.items():
733
735
  if filtered_relation := self._filtered_relations.get(field_name):
734
- relation = opts.get_field(filtered_relation.relation_name)
736
+ relation = meta.get_field(filtered_relation.relation_name)
735
737
  field_select_mask = select_mask.setdefault((field_name, relation), {})
736
738
  field = relation.field
737
739
  else:
738
- field = opts.get_field(field_name).field
740
+ field = meta.get_field(field_name).field
739
741
  field_select_mask = select_mask.setdefault(field, {})
740
742
  related_model = field.model
741
743
  self._get_defer_select_mask(
742
- related_model._meta, field_mask, field_select_mask
744
+ related_model._model_meta, field_mask, field_select_mask
743
745
  )
744
746
  return select_mask
745
747
 
746
748
  def _get_only_select_mask(
747
749
  self,
748
- opts: Options,
750
+ meta: Meta,
749
751
  mask: dict[str, Any],
750
752
  select_mask: dict[Any, Any] | None = None,
751
753
  ) -> dict[Any, Any]:
752
754
  if select_mask is None:
753
755
  select_mask = {}
754
- select_mask[opts.get_field("id")] = {}
756
+ select_mask[meta.get_field("id")] = {}
755
757
  # Only include fields mentioned in the mask.
756
758
  for field_name, field_mask in mask.items():
757
- field = opts.get_field(field_name)
759
+ field = meta.get_field(field_name)
758
760
  field_select_mask = select_mask.setdefault(field, {})
759
761
  if field_mask:
760
762
  if not field.is_relation:
761
763
  raise FieldError(next(iter(field_mask)))
762
764
  related_model = field.remote_field.model
763
765
  self._get_only_select_mask(
764
- related_model._meta, field_mask, field_select_mask
766
+ related_model._model_meta, field_mask, field_select_mask
765
767
  )
766
768
  return select_mask
767
769
 
@@ -782,10 +784,10 @@ class Query(BaseExpression):
782
784
  part_mask = mask
783
785
  for part in field_name.split(LOOKUP_SEP):
784
786
  part_mask = part_mask.setdefault(part, {})
785
- opts = self.get_meta()
787
+ meta = self.get_model_meta()
786
788
  if defer:
787
- return self._get_defer_select_mask(opts, mask)
788
- return self._get_only_select_mask(opts, mask)
789
+ return self._get_defer_select_mask(meta, mask)
790
+ return self._get_only_select_mask(meta, mask)
789
791
 
790
792
  def table_alias(
791
793
  self, table_name: str, create: bool = False, filtered_relation: Any = None
@@ -1000,7 +1002,9 @@ class Query(BaseExpression):
1000
1002
  alias = self.base_table
1001
1003
  self.ref_alias(alias)
1002
1004
  elif self.model:
1003
- alias = self.join(self.base_table_class(self.get_meta().db_table, None)) # type: ignore[arg-type]
1005
+ alias = self.join(
1006
+ self.base_table_class(self.model.model_options.db_table, None)
1007
+ )
1004
1008
  else:
1005
1009
  alias = None
1006
1010
  return alias
@@ -1110,7 +1114,7 @@ class Query(BaseExpression):
1110
1114
  for alias, table in query.alias_map.items():
1111
1115
  clone.external_aliases[alias] = (
1112
1116
  isinstance(table, Join)
1113
- and table.join_field.related_model._meta.db_table != alias
1117
+ and table.join_field.related_model.model_options.db_table != alias
1114
1118
  ) or (
1115
1119
  isinstance(table, BaseTable) and table.table_name != table.table_alias
1116
1120
  )
@@ -1191,46 +1195,48 @@ class Query(BaseExpression):
1191
1195
  if summarize:
1192
1196
  expression = Ref(annotation, expression)
1193
1197
  return expression_lookups, (), expression
1194
- _, field, _, lookup_parts = self.names_to_path(lookup_splitted, self.get_meta())
1198
+ _, field, _, lookup_parts = self.names_to_path(
1199
+ lookup_splitted, self.get_model_meta()
1200
+ )
1195
1201
  field_parts = lookup_splitted[0 : len(lookup_splitted) - len(lookup_parts)]
1196
1202
  if len(lookup_parts) > 1 and not field_parts:
1197
1203
  raise FieldError(
1198
- f'Invalid lookup "{lookup}" for model {self.get_meta().model.__name__}".'
1204
+ f'Invalid lookup "{lookup}" for model {self.get_model_meta().model.__name__}".'
1199
1205
  )
1200
1206
  return lookup_parts, field_parts, False # type: ignore[return-value]
1201
1207
 
1202
- def check_query_object_type(self, value: Any, opts: Options, field: Field) -> None:
1208
+ def check_query_object_type(self, value: Any, meta: Meta, field: Field) -> None:
1203
1209
  """
1204
1210
  Check whether the object passed while querying is of the correct type.
1205
1211
  If not, raise a ValueError specifying the wrong object.
1206
1212
  """
1207
- if hasattr(value, "_meta"):
1208
- if not check_rel_lookup_compatibility(value._meta.model, opts, field):
1213
+ if hasattr(value, "_model_meta"):
1214
+ if not check_rel_lookup_compatibility(value._model_meta.model, meta, field):
1209
1215
  raise ValueError(
1210
- f'Cannot query "{value}": Must be "{opts.object_name}" instance.'
1216
+ f'Cannot query "{value}": Must be "{meta.model.model_options.object_name}" instance.'
1211
1217
  )
1212
1218
 
1213
- def check_related_objects(self, field: Field, value: Any, opts: Options) -> None:
1219
+ def check_related_objects(self, field: Field, value: Any, meta: Meta) -> None:
1214
1220
  """Check the type of object passed to query relations."""
1215
1221
  if field.is_relation:
1216
1222
  # Check that the field and the queryset use the same model in a
1217
1223
  # query like .filter(author=Author.query.all()). For example, the
1218
- # opts would be Author's (from the author field) and value.model
1224
+ # meta would be Author's (from the author field) and value.model
1219
1225
  # would be Author.query.all() queryset's .model (Author also).
1220
1226
  # The field is the related field on the lhs side.
1221
1227
  if (
1222
1228
  isinstance(value, Query)
1223
1229
  and not value.has_select_fields
1224
- and not check_rel_lookup_compatibility(value.model, opts, field)
1230
+ and not check_rel_lookup_compatibility(value.model, meta, field)
1225
1231
  ):
1226
1232
  raise ValueError(
1227
- f'Cannot use QuerySet for "{value.model._meta.object_name}": Use a QuerySet for "{opts.object_name}".'
1233
+ f'Cannot use QuerySet for "{value.model.model_options.object_name}": Use a QuerySet for "{meta.model.model_options.object_name}".'
1228
1234
  )
1229
- elif hasattr(value, "_meta"):
1230
- self.check_query_object_type(value, opts, field)
1235
+ elif hasattr(value, "_model_meta"):
1236
+ self.check_query_object_type(value, meta, field)
1231
1237
  elif hasattr(value, "__iter__"):
1232
1238
  for v in value:
1233
- self.check_query_object_type(v, opts, field)
1239
+ self.check_query_object_type(v, meta, field)
1234
1240
 
1235
1241
  def check_filterable(self, expression: Any) -> None:
1236
1242
  """Raise an error if expression cannot be used in a WHERE clause."""
@@ -1391,14 +1397,14 @@ class Query(BaseExpression):
1391
1397
  condition = self.build_lookup(lookups, reffed_expression, value)
1392
1398
  return WhereNode([condition], connector=AND), [] # type: ignore[return-value]
1393
1399
 
1394
- opts = self.get_meta()
1400
+ meta = self.get_model_meta()
1395
1401
  alias = self.get_initial_alias()
1396
1402
  allow_many = not branch_negated or not split_subq
1397
1403
 
1398
1404
  try:
1399
1405
  join_info = self.setup_joins(
1400
1406
  parts,
1401
- opts,
1407
+ meta,
1402
1408
  alias,
1403
1409
  can_reuse=can_reuse,
1404
1410
  allow_many=allow_many,
@@ -1408,7 +1414,7 @@ class Query(BaseExpression):
1408
1414
  # Prevent iterator from being consumed by check_related_objects()
1409
1415
  if isinstance(value, Iterator):
1410
1416
  value = list(value)
1411
- self.check_related_objects(join_info.final_field, value, join_info.opts)
1417
+ self.check_related_objects(join_info.final_field, value, join_info.meta)
1412
1418
 
1413
1419
  # split_exclude() needs to know which joins were generated for the
1414
1420
  # lookup parts
@@ -1602,7 +1608,7 @@ class Query(BaseExpression):
1602
1608
  def names_to_path(
1603
1609
  self,
1604
1610
  names: list[str],
1605
- opts: Options | None,
1611
+ meta: Meta,
1606
1612
  allow_many: bool = True,
1607
1613
  fail_on_missing: bool = False,
1608
1614
  ) -> tuple[list[Any], Field, tuple[Field, ...], list[str]]:
@@ -1610,7 +1616,7 @@ class Query(BaseExpression):
1610
1616
  Walk the list of names and turns them into PathInfo tuples. A single
1611
1617
  name in 'names' can generate multiple PathInfos (m2m, for example).
1612
1618
 
1613
- 'names' is the path of names to travel, 'opts' is the model Options we
1619
+ 'names' is the path of names to travel, 'meta' is the Meta we
1614
1620
  start the name resolving from, 'allow_many' is as for setup_joins().
1615
1621
  If fail_on_missing is set to True, then a name that can't be resolved
1616
1622
  will generate a FieldError.
@@ -1627,9 +1633,9 @@ class Query(BaseExpression):
1627
1633
  field = None
1628
1634
  filtered_relation = None
1629
1635
  try:
1630
- if opts is None:
1636
+ if meta is None:
1631
1637
  raise FieldDoesNotExist
1632
- field = opts.get_field(name)
1638
+ field = meta.get_field(name)
1633
1639
  except FieldDoesNotExist:
1634
1640
  if name in self.annotation_select:
1635
1641
  field = self.annotation_select[name].output_field
@@ -1639,13 +1645,13 @@ class Query(BaseExpression):
1639
1645
  parts = filtered_relation.relation_name.split(LOOKUP_SEP)
1640
1646
  filtered_relation_path, field, _, _ = self.names_to_path(
1641
1647
  parts,
1642
- opts,
1648
+ meta,
1643
1649
  allow_many,
1644
1650
  fail_on_missing,
1645
1651
  )
1646
1652
  path.extend(filtered_relation_path[:-1])
1647
1653
  else:
1648
- field = opts.get_field(filtered_relation.relation_name) # type: ignore[attr-defined]
1654
+ field = meta.get_field(filtered_relation.relation_name) # type: ignore[attr-defined]
1649
1655
  if field is not None:
1650
1656
  # Fields that contain one-to-many relations with a generic
1651
1657
  # model (like a GenericForeignKey) cannot generate reverse
@@ -1664,7 +1670,7 @@ class Query(BaseExpression):
1664
1670
  if pos == -1 or fail_on_missing:
1665
1671
  available = sorted(
1666
1672
  [
1667
- *get_field_names_from_opts(opts),
1673
+ *get_field_names_from_opts(meta),
1668
1674
  *self.annotation_select,
1669
1675
  *self._filtered_relations,
1670
1676
  ]
@@ -1689,7 +1695,7 @@ class Query(BaseExpression):
1689
1695
  last = pathinfos[-1]
1690
1696
  path.extend(pathinfos)
1691
1697
  final_field = last.join_field
1692
- opts = last.to_opts
1698
+ meta = last.to_meta
1693
1699
  targets = last.target_fields
1694
1700
  cur_names_with_path[1].extend(pathinfos)
1695
1701
  names_with_path.append(cur_names_with_path)
@@ -1708,7 +1714,7 @@ class Query(BaseExpression):
1708
1714
  def setup_joins(
1709
1715
  self,
1710
1716
  names: list[str],
1711
- opts: Options,
1717
+ meta: Meta,
1712
1718
  alias: str,
1713
1719
  can_reuse: set[str] | None = None,
1714
1720
  allow_many: bool = True,
@@ -1716,7 +1722,7 @@ class Query(BaseExpression):
1716
1722
  ) -> JoinInfo:
1717
1723
  """
1718
1724
  Compute the necessary table joins for the passage through the fields
1719
- given in 'names'. 'opts' is the Options class for the current model
1725
+ given in 'names'. 'meta' is the Meta for the current model
1720
1726
  (which gives the table we are starting from), 'alias' is the alias for
1721
1727
  the table to start the joining from.
1722
1728
 
@@ -1762,7 +1768,7 @@ class Query(BaseExpression):
1762
1768
  try:
1763
1769
  path, final_field, targets, rest = self.names_to_path(
1764
1770
  names[:pivot],
1765
- opts,
1771
+ meta,
1766
1772
  allow_many,
1767
1773
  fail_on_missing=True,
1768
1774
  )
@@ -1807,13 +1813,13 @@ class Query(BaseExpression):
1807
1813
  else:
1808
1814
  filtered_relation = None
1809
1815
  table_alias = None
1810
- opts = join.to_opts
1816
+ meta = join.to_meta
1811
1817
  if join.direct:
1812
1818
  nullable = self.is_nullable(join.join_field)
1813
1819
  else:
1814
1820
  nullable = True
1815
1821
  connection = self.join_class(
1816
- opts.db_table,
1822
+ meta.model.model_options.db_table,
1817
1823
  alias,
1818
1824
  table_alias,
1819
1825
  INNER,
@@ -1830,7 +1836,7 @@ class Query(BaseExpression):
1830
1836
  joins.append(alias)
1831
1837
  if filtered_relation:
1832
1838
  filtered_relation.path = joins[:]
1833
- return JoinInfo(final_field, targets, opts, joins, path, final_transformer)
1839
+ return JoinInfo(final_field, targets, meta, joins, path, final_transformer)
1834
1840
 
1835
1841
  def trim_joins(
1836
1842
  self, targets: tuple[Field, ...], joins: list[str], path: list[Any]
@@ -1929,7 +1935,10 @@ class Query(BaseExpression):
1929
1935
  annotation = self.try_transform(annotation, transform)
1930
1936
  return annotation
1931
1937
  join_info = self.setup_joins(
1932
- field_list, self.get_meta(), self.get_initial_alias(), can_reuse=reuse
1938
+ field_list,
1939
+ self.get_model_meta(),
1940
+ self.get_initial_alias(),
1941
+ can_reuse=reuse,
1933
1942
  )
1934
1943
  targets, final_alias, join_list = self.trim_joins(
1935
1944
  join_info.targets, join_info.joins, join_info.path
@@ -1991,7 +2000,7 @@ class Query(BaseExpression):
1991
2000
  select_field = col.target
1992
2001
  alias = col.alias
1993
2002
  if alias in can_reuse:
1994
- id_field = select_field.model._meta.get_field("id")
2003
+ id_field = select_field.model._model_meta.get_field("id")
1995
2004
  # Need to add a restriction so that outer query's filters are in effect for
1996
2005
  # the subquery, too.
1997
2006
  query.bump_prefix(self)
@@ -2115,7 +2124,7 @@ class Query(BaseExpression):
2115
2124
  the order specified.
2116
2125
  """
2117
2126
  alias = self.get_initial_alias()
2118
- opts = self.get_meta()
2127
+ meta = self.get_model_meta()
2119
2128
 
2120
2129
  try:
2121
2130
  cols = []
@@ -2123,7 +2132,7 @@ class Query(BaseExpression):
2123
2132
  # Join promotion note - we must not remove any rows here, so
2124
2133
  # if there is no existing joins, use outer join.
2125
2134
  join_info = self.setup_joins(
2126
- name.split(LOOKUP_SEP), opts, alias, allow_many=allow_m2m
2135
+ name.split(LOOKUP_SEP), meta, alias, allow_many=allow_m2m
2127
2136
  )
2128
2137
  targets, final_alias, joins = self.trim_joins(
2129
2138
  join_info.targets,
@@ -2148,7 +2157,7 @@ class Query(BaseExpression):
2148
2157
  else:
2149
2158
  names = sorted(
2150
2159
  [
2151
- *get_field_names_from_opts(opts),
2160
+ *get_field_names_from_opts(meta),
2152
2161
  *self.extra,
2153
2162
  *self.annotation_select,
2154
2163
  *self._filtered_relations,
@@ -2181,7 +2190,7 @@ class Query(BaseExpression):
2181
2190
  continue
2182
2191
  # names_to_path() validates the lookup. A descriptive
2183
2192
  # FieldError will be raise if it's not.
2184
- self.names_to_path(item.split(LOOKUP_SEP), self.model._meta)
2193
+ self.names_to_path(item.split(LOOKUP_SEP), self.model._model_meta)
2185
2194
  elif not hasattr(item, "resolve_expression"):
2186
2195
  errors.append(item)
2187
2196
  if getattr(item, "contains_aggregate", False):
@@ -2404,13 +2413,13 @@ class Query(BaseExpression):
2404
2413
  self.set_annotation_mask(annotation_names)
2405
2414
  selected = frozenset(field_names + extra_names + annotation_names)
2406
2415
  else:
2407
- field_names = [f.attname for f in self.model._meta.concrete_fields]
2416
+ field_names = [f.attname for f in self.model._model_meta.concrete_fields]
2408
2417
  selected = frozenset(field_names)
2409
2418
  # Selected annotations must be known before setting the GROUP BY
2410
2419
  # clause.
2411
2420
  if self.group_by is True:
2412
2421
  self.add_fields(
2413
- (f.attname for f in self.model._meta.concrete_fields), False
2422
+ (f.attname for f in self.model._model_meta.concrete_fields), False
2414
2423
  )
2415
2424
  # Disable GROUP BY aliases to avoid orphaning references to the
2416
2425
  # SELECT clause which is about to be cleared.
@@ -36,14 +36,14 @@ class DeleteQuery(Query):
36
36
  """
37
37
  # number of objects deleted
38
38
  num_deleted = 0
39
- field = self.get_meta().get_field("id")
39
+ field = self.get_model_meta().get_field("id")
40
40
  for offset in range(0, len(id_list), GET_ITERATOR_CHUNK_SIZE):
41
41
  self.clear_where()
42
42
  self.add_filter(
43
43
  f"{field.attname}__in",
44
44
  id_list[offset : offset + GET_ITERATOR_CHUNK_SIZE],
45
45
  )
46
- num_deleted += self.do_query(self.get_meta().db_table, self.where)
46
+ num_deleted += self.do_query(self.model.model_options.db_table, self.where)
47
47
  return num_deleted
48
48
 
49
49
 
@@ -87,7 +87,7 @@ class UpdateQuery(Query):
87
87
  """
88
88
  values_seq = []
89
89
  for name, val in values.items():
90
- field = self.get_meta().get_field(name)
90
+ field = self.get_model_meta().get_field(name)
91
91
  direct = (
92
92
  not (field.auto_created and not field.concrete) or not field.concrete
93
93
  )
@@ -97,7 +97,7 @@ class UpdateQuery(Query):
97
97
  f"Cannot update model field {field!r} (only non-relations and "
98
98
  "foreign keys permitted)."
99
99
  )
100
- if model is not self.get_meta().model:
100
+ if model is not self.get_model_meta().model:
101
101
  self.add_related_update(model, field, val)
102
102
  continue
103
103
  values_seq.append((field, model, val))
plain/models/utils.py CHANGED
@@ -19,7 +19,10 @@ def make_model_tuple(model: Any) -> tuple[str, str]:
19
19
  package_label, model_name = model.split(".")
20
20
  model_tuple = package_label, model_name.lower()
21
21
  else:
22
- model_tuple = model._meta.package_label, model._meta.model_name
22
+ model_tuple = (
23
+ model.model_options.package_label,
24
+ model.model_options.model_name,
25
+ )
23
26
  assert len(model_tuple) == 2
24
27
  return model_tuple
25
28
  except (ValueError, AssertionError):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain.models
3
- Version: 0.50.0
3
+ Version: 0.51.0
4
4
  Summary: Model your data and store it in a database.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
6
  License-File: LICENSE
@@ -211,31 +211,32 @@ class User(models.Model):
211
211
  username = models.CharField(max_length=150)
212
212
  age = models.IntegerField()
213
213
 
214
- class Meta:
215
- indexes = [
214
+ model_options = models.Options(
215
+ indexes=[
216
216
  models.Index(fields=["email"]),
217
217
  models.Index(fields=["-created_at"], name="user_created_idx"),
218
- ]
219
- constraints = [
218
+ ],
219
+ constraints=[
220
220
  models.UniqueConstraint(fields=["email", "username"], name="unique_user"),
221
221
  models.CheckConstraint(check=models.Q(age__gte=0), name="age_positive"),
222
- ]
222
+ ],
223
+ )
223
224
  ```
224
225
 
225
226
  ## Custom QuerySets
226
227
 
227
- With the Manager functionality now merged into QuerySet, you can customize [`QuerySet`](./query.py#QuerySet) classes to provide specialized query methods. There are several ways to use custom QuerySets:
228
-
229
- ### Setting a default QuerySet for a model
228
+ With the Manager functionality now merged into QuerySet, you can customize [`QuerySet`](./query.py#QuerySet) classes to provide specialized query methods.
230
229
 
231
- Use `Meta.queryset_class` to set a custom QuerySet that will be used by `Model.query`:
230
+ Define a custom QuerySet and assign it to your model's `query` attribute:
232
231
 
233
232
  ```python
234
- class PublishedQuerySet(models.QuerySet):
235
- def published_only(self):
233
+ from typing import Self
234
+
235
+ class PublishedQuerySet(models.QuerySet["Article"]):
236
+ def published_only(self) -> Self:
236
237
  return self.filter(status="published")
237
238
 
238
- def draft_only(self):
239
+ def draft_only(self) -> Self:
239
240
  return self.filter(status="draft")
240
241
 
241
242
  @models.register_model
@@ -243,50 +244,33 @@ class Article(models.Model):
243
244
  title = models.CharField(max_length=200)
244
245
  status = models.CharField(max_length=20)
245
246
 
246
- class Meta:
247
- queryset_class = PublishedQuerySet
247
+ query = PublishedQuerySet()
248
248
 
249
- # Usage - all methods available on Article.objects
249
+ # Usage - all methods available on Article.query
250
250
  all_articles = Article.query.all()
251
251
  published_articles = Article.query.published_only()
252
252
  draft_articles = Article.query.draft_only()
253
253
  ```
254
254
 
255
- ### Using custom QuerySets without formal attachment
256
-
257
- You can also use custom QuerySets manually without setting them as the default:
255
+ Custom methods can be chained with built-in QuerySet methods:
258
256
 
259
257
  ```python
260
- class SpecialQuerySet(models.QuerySet):
261
- def special_filter(self):
262
- return self.filter(special=True)
263
-
264
- # Create and use the QuerySet manually
265
- special_qs = SpecialQuerySet(model=Article)
266
- special_articles = special_qs.special_filter()
258
+ # Chaining works naturally
259
+ recent_published = Article.query.published_only().order_by("-created_at")[:10]
267
260
  ```
268
261
 
269
- ### Using classmethods for convenience
262
+ ### Programmatic QuerySet usage
270
263
 
271
- For even cleaner API, add classmethods to your model:
264
+ For internal code that needs to create QuerySet instances programmatically, use `from_model()`:
272
265
 
273
266
  ```python
274
- @models.register_model
275
- class Article(models.Model):
276
- title = models.CharField(max_length=200)
277
- status = models.CharField(max_length=20)
278
-
279
- @classmethod
280
- def published(cls):
281
- return PublishedQuerySet(model=cls).published_only()
282
-
283
- @classmethod
284
- def drafts(cls):
285
- return PublishedQuerySet(model=cls).draft_only()
267
+ class SpecialQuerySet(models.QuerySet["Article"]):
268
+ def special_filter(self) -> Self:
269
+ return self.filter(special=True)
286
270
 
287
- # Usage
288
- published_articles = Article.published()
289
- draft_articles = Article.drafts()
271
+ # Create and use the QuerySet programmatically
272
+ special_qs = SpecialQuerySet.from_model(Article)
273
+ special_articles = special_qs.special_filter()
290
274
  ```
291
275
 
292
276
  ## Forms