plain.models 0.34.3__tar.gz → 0.35.0__tar.gz

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 (131) hide show
  1. {plain_models-0.34.3 → plain_models-0.35.0}/.gitignore +2 -0
  2. {plain_models-0.34.3 → plain_models-0.35.0}/PKG-INFO +1 -1
  3. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/CHANGELOG.md +22 -0
  4. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/base/features.py +0 -3
  5. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/base/schema.py +14 -29
  6. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/mysql/base.py +5 -48
  7. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/mysql/schema.py +3 -1
  8. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/base.py +1 -4
  9. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/cli.py +31 -0
  10. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/constraints.py +1 -5
  11. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/deletion.py +8 -8
  12. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/fields/__init__.py +2 -10
  13. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/fields/related.py +1 -7
  14. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/fields/related_descriptors.py +1 -4
  15. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/forms.py +1 -7
  16. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/sql/query.py +2 -21
  17. {plain_models-0.34.3 → plain_models-0.35.0}/pyproject.toml +1 -1
  18. plain_models-0.35.0/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +101 -0
  19. plain_models-0.35.0/tests/app/examples/models.py +62 -0
  20. {plain_models-0.34.3 → plain_models-0.35.0}/tests/app/settings.py +0 -4
  21. plain_models-0.35.0/tests/test_delete_behaviors.py +73 -0
  22. plain_models-0.34.3/tests/app/examples/models.py +0 -16
  23. {plain_models-0.34.3 → plain_models-0.35.0}/LICENSE +0 -0
  24. {plain_models-0.34.3 → plain_models-0.35.0}/README.md +0 -0
  25. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/README.md +0 -0
  26. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/__init__.py +0 -0
  27. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/aggregates.py +0 -0
  28. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/__init__.py +0 -0
  29. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/base/__init__.py +0 -0
  30. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/base/base.py +0 -0
  31. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/base/client.py +0 -0
  32. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/base/creation.py +0 -0
  33. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/base/introspection.py +0 -0
  34. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/base/operations.py +0 -0
  35. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/base/validation.py +0 -0
  36. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/ddl_references.py +0 -0
  37. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/mysql/__init__.py +0 -0
  38. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/mysql/client.py +0 -0
  39. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/mysql/compiler.py +0 -0
  40. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/mysql/creation.py +0 -0
  41. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/mysql/features.py +0 -0
  42. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/mysql/introspection.py +0 -0
  43. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/mysql/operations.py +0 -0
  44. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/mysql/validation.py +0 -0
  45. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/postgresql/__init__.py +0 -0
  46. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/postgresql/base.py +0 -0
  47. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/postgresql/client.py +0 -0
  48. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/postgresql/creation.py +0 -0
  49. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/postgresql/features.py +0 -0
  50. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/postgresql/introspection.py +0 -0
  51. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/postgresql/operations.py +0 -0
  52. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/postgresql/schema.py +0 -0
  53. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/sqlite3/__init__.py +0 -0
  54. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/sqlite3/_functions.py +0 -0
  55. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/sqlite3/base.py +0 -0
  56. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/sqlite3/client.py +0 -0
  57. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/sqlite3/creation.py +0 -0
  58. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/sqlite3/features.py +0 -0
  59. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/sqlite3/introspection.py +0 -0
  60. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/sqlite3/operations.py +0 -0
  61. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/sqlite3/schema.py +0 -0
  62. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backends/utils.py +0 -0
  63. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backups/__init__.py +0 -0
  64. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backups/cli.py +0 -0
  65. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backups/clients.py +0 -0
  66. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/backups/core.py +0 -0
  67. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/config.py +0 -0
  68. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/connections.py +0 -0
  69. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/constants.py +0 -0
  70. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/database_url.py +0 -0
  71. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/db.py +0 -0
  72. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/default_settings.py +0 -0
  73. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/entrypoints.py +0 -0
  74. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/enums.py +0 -0
  75. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/exceptions.py +0 -0
  76. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/expressions.py +0 -0
  77. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/fields/json.py +0 -0
  78. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/fields/mixins.py +0 -0
  79. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/fields/related_lookups.py +0 -0
  80. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/fields/reverse_related.py +0 -0
  81. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/functions/__init__.py +0 -0
  82. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/functions/comparison.py +0 -0
  83. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/functions/datetime.py +0 -0
  84. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/functions/math.py +0 -0
  85. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/functions/mixins.py +0 -0
  86. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/functions/text.py +0 -0
  87. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/functions/window.py +0 -0
  88. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/indexes.py +0 -0
  89. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/lookups.py +0 -0
  90. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/manager.py +0 -0
  91. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/migrations/__init__.py +0 -0
  92. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/migrations/autodetector.py +0 -0
  93. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/migrations/exceptions.py +0 -0
  94. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/migrations/executor.py +0 -0
  95. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/migrations/graph.py +0 -0
  96. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/migrations/loader.py +0 -0
  97. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/migrations/migration.py +0 -0
  98. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/migrations/operations/__init__.py +0 -0
  99. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/migrations/operations/base.py +0 -0
  100. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/migrations/operations/fields.py +0 -0
  101. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/migrations/operations/models.py +0 -0
  102. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/migrations/operations/special.py +0 -0
  103. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/migrations/optimizer.py +0 -0
  104. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/migrations/questioner.py +0 -0
  105. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/migrations/recorder.py +0 -0
  106. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/migrations/serializer.py +0 -0
  107. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/migrations/state.py +0 -0
  108. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/migrations/utils.py +0 -0
  109. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/migrations/writer.py +0 -0
  110. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/options.py +0 -0
  111. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/preflight.py +0 -0
  112. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/query.py +0 -0
  113. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/query_utils.py +0 -0
  114. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/registry.py +0 -0
  115. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/sql/__init__.py +0 -0
  116. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/sql/compiler.py +0 -0
  117. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/sql/constants.py +0 -0
  118. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/sql/datastructures.py +0 -0
  119. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/sql/subqueries.py +0 -0
  120. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/sql/where.py +0 -0
  121. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/test/__init__.py +0 -0
  122. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/test/pytest.py +0 -0
  123. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/test/utils.py +0 -0
  124. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/transaction.py +0 -0
  125. {plain_models-0.34.3 → plain_models-0.35.0}/plain/models/utils.py +0 -0
  126. {plain_models-0.34.3 → plain_models-0.35.0}/tests/app/examples/migrations/0001_initial.py +0 -0
  127. {plain_models-0.34.3 → plain_models-0.35.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
  128. {plain_models-0.34.3 → plain_models-0.35.0}/tests/app/examples/migrations/__init__.py +0 -0
  129. {plain_models-0.34.3 → plain_models-0.35.0}/tests/app/urls.py +0 -0
  130. {plain_models-0.34.3 → plain_models-0.35.0}/tests/test_database_url.py +0 -0
  131. {plain_models-0.34.3 → plain_models-0.35.0}/tests/test_models.py +0 -0
@@ -16,3 +16,5 @@ plain*/tests/.plain
16
16
 
17
17
  # Plain temp dirs
18
18
  .plain
19
+
20
+ coverage.xml
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain.models
3
- Version: 0.34.3
3
+ Version: 0.35.0
4
4
  Summary: Database models for Plain.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
6
  License-File: LICENSE
@@ -1,5 +1,27 @@
1
1
  # plain-models changelog
2
2
 
3
+ ## [0.35.0](https://github.com/dropseed/plain/releases/plain-models@0.35.0) (2025-07-07)
4
+
5
+ ### What's changed
6
+
7
+ - Added the `plain models list` CLI command which prints a nicely formatted list of all installed models, including their table name, fields, and originating package. You can pass package labels to filter the output or use the `--app-only` flag to only show first-party app models ([1bc40ce](https://github.com/dropseed/plain/commit/1bc40ce)).
8
+ - The MySQL backend no longer enforces a strict `mysqlclient >= 1.4.3` version check and had several unused constraint-handling methods removed, reducing boilerplate and improving compatibility with a wider range of `mysqlclient` versions ([6322400](https://github.com/dropseed/plain/commit/6322400), [67f21f6](https://github.com/dropseed/plain/commit/67f21f6)).
9
+
10
+ ### Upgrade instructions
11
+
12
+ - No changes required
13
+
14
+ ## [0.34.4](https://github.com/dropseed/plain/releases/plain-models@0.34.4) (2025-07-02)
15
+
16
+ ### What's changed
17
+
18
+ - The built-in `on_delete` behaviors (`CASCADE`, `PROTECT`, `RESTRICT`, `SET_NULL`, `SET_DEFAULT`, and the callables returned by `SET(...)`) no longer receive the legacy `using` argument. Their signatures are now `(collector, field, sub_objs)` ([20325a1](https://github.com/dropseed/plain/commit/20325a1)).
19
+ - Removed the unused `interprets_empty_strings_as_nulls` backend feature flag and the related fallback logic ([285378c](https://github.com/dropseed/plain/commit/285378c)).
20
+
21
+ ### Upgrade instructions
22
+
23
+ - No changes required
24
+
3
25
  ## [0.34.3](https://github.com/dropseed/plain/releases/plain-models@0.34.3) (2025-06-29)
4
26
 
5
27
  ### What's changed
@@ -9,9 +9,6 @@ class BaseDatabaseFeatures:
9
9
  empty_fetchmany_value = []
10
10
  update_can_self_select = True
11
11
 
12
- # Does the backend distinguish between '' and None?
13
- interprets_empty_strings_as_nulls = False
14
-
15
12
  # Does the backend support initially deferrable unique constraints?
16
13
  supports_deferrable_unique_constraints = False
17
14
 
@@ -297,14 +297,6 @@ class BaseDatabaseSchemaEditor:
297
297
  else:
298
298
  yield column_default
299
299
  params.append(default_value)
300
- # Oracle treats the empty string ('') as null, so coerce the null
301
- # option whenever '' is a possible value.
302
- if (
303
- field.empty_strings_allowed
304
- and not field.primary_key
305
- and self.connection.features.interprets_empty_strings_as_nulls
306
- ):
307
- null = True
308
300
 
309
301
  if not null:
310
302
  yield "NOT NULL"
@@ -1042,27 +1034,20 @@ class BaseDatabaseSchemaEditor:
1042
1034
  Return a (sql, params) fragment to set a column to null or non-null
1043
1035
  as required by new_field, or None if no changes are required.
1044
1036
  """
1045
- if (
1046
- self.connection.features.interprets_empty_strings_as_nulls
1047
- and new_field.empty_strings_allowed
1048
- ):
1049
- # The field is nullable in the database anyway, leave it alone.
1050
- return
1051
- else:
1052
- new_db_params = new_field.db_parameters(connection=self.connection)
1053
- sql = (
1054
- self.sql_alter_column_null
1055
- if new_field.allow_null
1056
- else self.sql_alter_column_not_null
1057
- )
1058
- return (
1059
- sql
1060
- % {
1061
- "column": self.quote_name(new_field.column),
1062
- "type": new_db_params["type"],
1063
- },
1064
- [],
1065
- )
1037
+ new_db_params = new_field.db_parameters(connection=self.connection)
1038
+ sql = (
1039
+ self.sql_alter_column_null
1040
+ if new_field.allow_null
1041
+ else self.sql_alter_column_not_null
1042
+ )
1043
+ return (
1044
+ sql
1045
+ % {
1046
+ "column": self.quote_name(new_field.column),
1047
+ "type": new_db_params["type"],
1048
+ },
1049
+ [],
1050
+ )
1066
1051
 
1067
1052
  def _alter_column_default_sql(self, model, old_field, new_field, drop=False):
1068
1053
  """
@@ -6,23 +6,16 @@ Requires mysqlclient: https://pypi.org/project/mysqlclient/
6
6
 
7
7
  from functools import cached_property
8
8
 
9
+ import MySQLdb as Database
10
+ from MySQLdb.constants import CLIENT, FIELD_TYPE
11
+ from MySQLdb.converters import conversions
12
+
9
13
  from plain.exceptions import ImproperlyConfigured
10
14
  from plain.models.backends import utils as backend_utils
11
15
  from plain.models.backends.base.base import BaseDatabaseWrapper
12
16
  from plain.models.db import IntegrityError
13
17
  from plain.utils.regex_helper import _lazy_re_compile
14
18
 
15
- try:
16
- import MySQLdb as Database
17
- except ImportError as err:
18
- raise ImproperlyConfigured(
19
- "Error loading MySQLdb module.\nDid you install mysqlclient?"
20
- ) from err
21
-
22
- from MySQLdb.constants import CLIENT, FIELD_TYPE
23
- from MySQLdb.converters import conversions
24
-
25
- # Some of these import MySQLdb, so import them after checking if it's installed.
26
19
  from .client import DatabaseClient
27
20
  from .creation import DatabaseCreation
28
21
  from .features import DatabaseFeatures
@@ -31,13 +24,6 @@ from .operations import DatabaseOperations
31
24
  from .schema import DatabaseSchemaEditor
32
25
  from .validation import DatabaseValidation
33
26
 
34
- version = Database.version_info
35
- if version < (1, 4, 3):
36
- raise ImproperlyConfigured(
37
- f"mysqlclient 1.4.3 or newer is required; you have {Database.__version__}."
38
- )
39
-
40
-
41
27
  # MySQLdb returns TIME columns as timedelta -- they are more like timedelta in
42
28
  # terms of actual behavior as they are signed and include days -- and Plain
43
29
  # expects time.
@@ -281,37 +267,8 @@ class DatabaseWrapper(BaseDatabaseWrapper):
281
267
  with self.wrap_database_errors:
282
268
  self.connection.autocommit(autocommit)
283
269
 
284
- def disable_constraint_checking(self):
285
- """
286
- Disable foreign key checks, primarily for use in adding rows with
287
- forward references. Always return True to indicate constraint checks
288
- need to be re-enabled.
289
- """
290
- with self.cursor() as cursor:
291
- cursor.execute("SET foreign_key_checks=0")
292
- return True
293
-
294
- def enable_constraint_checking(self):
295
- """
296
- Re-enable foreign key checks after they have been disabled.
297
- """
298
- # Override needs_rollback in case constraint_checks_disabled is
299
- # nested inside transaction.atomic.
300
- self.needs_rollback, needs_rollback = False, self.needs_rollback
301
- try:
302
- with self.cursor() as cursor:
303
- cursor.execute("SET foreign_key_checks=1")
304
- finally:
305
- self.needs_rollback = needs_rollback
306
-
307
270
  def check_constraints(self, table_names=None):
308
- """
309
- Check each table name in `table_names` for rows with invalid foreign
310
- key references. This method is intended to be used in conjunction with
311
- `disable_constraint_checking()` and `enable_constraint_checking()`, to
312
- determine if rows with invalid references were entered while constraint
313
- checks were off.
314
- """
271
+ """Check ``table_names`` for rows with invalid foreign key references."""
315
272
  with self.cursor() as cursor:
316
273
  if table_names is None:
317
274
  table_names = self.introspection.table_names(cursor)
@@ -1,6 +1,8 @@
1
1
  from plain.models.backends.base.schema import BaseDatabaseSchemaEditor
2
2
  from plain.models.constants import LOOKUP_SEP
3
- from plain.models.fields import NOT_PROVIDED, F, UniqueConstraint
3
+ from plain.models.constraints import UniqueConstraint
4
+ from plain.models.expressions import F
5
+ from plain.models.fields import NOT_PROVIDED
4
6
 
5
7
 
6
8
  class DatabaseSchemaEditor(BaseDatabaseSchemaEditor):
@@ -833,10 +833,7 @@ class Model(metaclass=ModelBase):
833
833
  f = self._meta.get_field(field_name)
834
834
  lookup_value = getattr(self, f.attname)
835
835
  # TODO: Handle multiple backends with different feature flags.
836
- if lookup_value is None or (
837
- lookup_value == ""
838
- and db_connection.features.interprets_empty_strings_as_nulls
839
- ):
836
+ if lookup_value is None:
840
837
  # no value, skip the lookup
841
838
  continue
842
839
  if f.primary_key and not self._state.adding:
@@ -96,6 +96,37 @@ def db_wait():
96
96
  break
97
97
 
98
98
 
99
+ @cli.command(name="list")
100
+ @click.argument("package_labels", nargs=-1)
101
+ @click.option(
102
+ "--app-only",
103
+ is_flag=True,
104
+ help="Only show models from packages that start with 'app'.",
105
+ )
106
+ def list_models(package_labels, app_only):
107
+ """List installed models."""
108
+
109
+ packages = set(package_labels)
110
+
111
+ for model in sorted(
112
+ models_registry.get_models(),
113
+ key=lambda m: (m._meta.package_label, m._meta.model_name),
114
+ ):
115
+ pkg = model._meta.package_label
116
+ pkg_name = packages_registry.get_package_config(pkg).name
117
+ if app_only and not pkg_name.startswith("app"):
118
+ continue
119
+ if packages and pkg not in packages:
120
+ continue
121
+ fields = ", ".join(f.name for f in model._meta.get_fields())
122
+ click.echo(
123
+ f"{click.style(pkg, fg='cyan')}.{click.style(model.__name__, fg='blue')}"
124
+ )
125
+ click.echo(f" table: {model._meta.db_table}")
126
+ click.echo(f" fields: {fields}")
127
+ click.echo(f" package: {pkg_name}\n")
128
+
129
+
99
130
  @register_cli("makemigrations")
100
131
  @cli.command()
101
132
  @click.argument("package_labels", nargs=-1)
@@ -2,7 +2,6 @@ from enum import Enum
2
2
  from types import NoneType
3
3
 
4
4
  from plain.exceptions import FieldError, ValidationError
5
- from plain.models.db import db_connection
6
5
  from plain.models.expressions import Exists, ExpressionList, F, OrderBy
7
6
  from plain.models.indexes import IndexExpression
8
7
  from plain.models.lookups import Exact
@@ -356,10 +355,7 @@ class UniqueConstraint(BaseConstraint):
356
355
  return
357
356
  field = model._meta.get_field(field_name)
358
357
  lookup_value = getattr(instance, field.attname)
359
- if lookup_value is None or (
360
- lookup_value == ""
361
- and db_connection.features.interprets_empty_strings_as_nulls
362
- ):
358
+ if lookup_value is None:
363
359
  # A composite constraint containing NULL value cannot cause
364
360
  # a violation since NULL != NULL in SQL.
365
361
  return
@@ -24,7 +24,7 @@ class RestrictedError(IntegrityError):
24
24
  super().__init__(msg, restricted_objects)
25
25
 
26
26
 
27
- def CASCADE(collector, field, sub_objs, using):
27
+ def CASCADE(collector, field, sub_objs):
28
28
  collector.collect(
29
29
  sub_objs,
30
30
  source=field.remote_field.model,
@@ -35,7 +35,7 @@ def CASCADE(collector, field, sub_objs, using):
35
35
  collector.add_field_update(field, None, sub_objs)
36
36
 
37
37
 
38
- def PROTECT(collector, field, sub_objs, using):
38
+ def PROTECT(collector, field, sub_objs):
39
39
  raise ProtectedError(
40
40
  f"Cannot delete some instances of model '{field.remote_field.model.__name__}' because they are "
41
41
  f"referenced through a protected foreign key: '{sub_objs[0].__class__.__name__}.{field.name}'",
@@ -43,7 +43,7 @@ def PROTECT(collector, field, sub_objs, using):
43
43
  )
44
44
 
45
45
 
46
- def RESTRICT(collector, field, sub_objs, using):
46
+ def RESTRICT(collector, field, sub_objs):
47
47
  collector.add_restricted_objects(field, sub_objs)
48
48
  collector.add_dependency(field.remote_field.model, field.model)
49
49
 
@@ -51,12 +51,12 @@ def RESTRICT(collector, field, sub_objs, using):
51
51
  def SET(value):
52
52
  if callable(value):
53
53
 
54
- def set_on_delete(collector, field, sub_objs, using):
54
+ def set_on_delete(collector, field, sub_objs):
55
55
  collector.add_field_update(field, value(), sub_objs)
56
56
 
57
57
  else:
58
58
 
59
- def set_on_delete(collector, field, sub_objs, using):
59
+ def set_on_delete(collector, field, sub_objs):
60
60
  collector.add_field_update(field, value, sub_objs)
61
61
 
62
62
  set_on_delete.deconstruct = lambda: ("plain.models.SET", (value,), {})
@@ -64,21 +64,21 @@ def SET(value):
64
64
  return set_on_delete
65
65
 
66
66
 
67
- def SET_NULL(collector, field, sub_objs, using):
67
+ def SET_NULL(collector, field, sub_objs):
68
68
  collector.add_field_update(field, None, sub_objs)
69
69
 
70
70
 
71
71
  SET_NULL.lazy_sub_objs = True
72
72
 
73
73
 
74
- def SET_DEFAULT(collector, field, sub_objs, using):
74
+ def SET_DEFAULT(collector, field, sub_objs):
75
75
  collector.add_field_update(field, field.get_default(), sub_objs)
76
76
 
77
77
 
78
78
  SET_DEFAULT.lazy_sub_objs = True
79
79
 
80
80
 
81
- def DO_NOTHING(collector, field, sub_objs, using):
81
+ def DO_NOTHING(collector, field, sub_objs):
82
82
  pass
83
83
 
84
84
 
@@ -365,11 +365,7 @@ class Field(RegisterLookupMixin):
365
365
  return errors
366
366
 
367
367
  def _check_null_allowed_for_primary_keys(self):
368
- if (
369
- self.primary_key
370
- and self.allow_null
371
- and not db_connection.features.interprets_empty_strings_as_nulls
372
- ):
368
+ if self.primary_key and self.allow_null:
373
369
  # We cannot reliably check this for backends like Oracle which
374
370
  # consider NULL and '' to be equal (and thus set up
375
371
  # character-based fields a little differently).
@@ -885,11 +881,7 @@ class Field(RegisterLookupMixin):
885
881
  return self.default
886
882
  return lambda: self.default
887
883
 
888
- if (
889
- not self.empty_strings_allowed
890
- or self.allow_null
891
- and not db_connection.features.interprets_empty_strings_as_nulls
892
- ):
884
+ if not self.empty_strings_allowed or self.allow_null:
893
885
  return return_None
894
886
  return str # return empty string
895
887
 
@@ -974,11 +974,7 @@ class ForeignKey(ForeignObject):
974
974
 
975
975
  def get_db_prep_save(self, value, connection):
976
976
  if value is None or (
977
- value == ""
978
- and (
979
- not self.target_field.empty_strings_allowed
980
- or connection.features.interprets_empty_strings_as_nulls
981
- )
977
+ value == "" and not self.target_field.empty_strings_allowed
982
978
  ):
983
979
  return None
984
980
  else:
@@ -1019,8 +1015,6 @@ class ForeignKey(ForeignObject):
1019
1015
 
1020
1016
  def get_db_converters(self, connection):
1021
1017
  converters = super().get_db_converters(connection)
1022
- if connection.features.interprets_empty_strings_as_nulls:
1023
- converters += [self.convert_empty_strings]
1024
1018
  return converters
1025
1019
 
1026
1020
  def get_col(self, alias, output_field=None):
@@ -369,15 +369,12 @@ def create_reverse_many_to_one_manager(superclass, rel):
369
369
  """
370
370
  Filter the queryset for the instance this manager is bound to.
371
371
  """
372
- empty_strings_as_null = (
373
- db_connection.features.interprets_empty_strings_as_nulls
374
- )
375
372
  queryset._add_hints(instance=self.instance)
376
373
  queryset._defer_next_filter = True
377
374
  queryset = queryset.filter(**self.core_filters)
378
375
  for field in self.field.foreign_related_fields:
379
376
  val = getattr(self.instance, field.attname)
380
- if val is None or (val == "" and empty_strings_as_null):
377
+ if val is None:
381
378
  return queryset.none()
382
379
  if self.field.many_to_one:
383
380
  # Guard against field-like objects such as GenericRelation
@@ -769,13 +769,7 @@ def modelfield_to_formfield(
769
769
  # Passing max_length to forms.CharField means that the value's length
770
770
  # will be validated twice. This is considered acceptable since we want
771
771
  # the value in the form field (to pass into widget for example).
772
- # TODO: Handle multiple backends with different feature flags.
773
- from plain.models.db import db_connection
774
-
775
- if (
776
- modelfield.allow_null
777
- and not db_connection.features.interprets_empty_strings_as_nulls
778
- ):
772
+ if modelfield.allow_null:
779
773
  defaults["empty_value"] = None
780
774
  return fields.CharField(
781
775
  max_length=modelfield.max_length,
@@ -1233,16 +1233,6 @@ class Query(BaseExpression):
1233
1233
  raise ValueError("Cannot use None as a query value")
1234
1234
  return lhs.get_lookup("isnull")(lhs, True)
1235
1235
 
1236
- # For Oracle '' is equivalent to null. The check must be done at this
1237
- # stage because join promotion can't be done in the compiler. A similar
1238
- # thing is done in is_nullable(), too.
1239
- if (
1240
- lookup_name == "exact"
1241
- and lookup.rhs == ""
1242
- and db_connection.features.interprets_empty_strings_as_nulls
1243
- ):
1244
- return lhs.get_lookup("isnull")(lhs, True)
1245
-
1246
1236
  return lookup
1247
1237
 
1248
1238
  def try_transform(self, lhs, name):
@@ -2466,20 +2456,11 @@ class Query(BaseExpression):
2466
2456
  return trimmed_prefix, contains_louter
2467
2457
 
2468
2458
  def is_nullable(self, field):
2469
- """
2470
- Check if the given field should be treated as nullable.
2471
-
2472
- Some backends treat '' as null and Plain treats such fields as
2473
- nullable for those backends. In such situations field.allow_null can be
2474
- False even if we should treat the field as nullable.
2475
- """
2459
+ """Check if the given field should be treated as nullable."""
2476
2460
  # QuerySet does not have knowledge of which connection is going to be
2477
2461
  # used. For the single-database setup we always reference the default
2478
2462
  # connection here.
2479
- return field.allow_null or (
2480
- field.empty_strings_allowed
2481
- and db_connection.features.interprets_empty_strings_as_nulls
2482
- )
2463
+ return field.allow_null
2483
2464
 
2484
2465
 
2485
2466
  def get_order_dir(field, default="ASC"):
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "plain.models"
3
- version = "0.34.3"
3
+ version = "0.35.0"
4
4
  description = "Database models for Plain."
5
5
  authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}]
6
6
  readme = "README.md"
@@ -0,0 +1,101 @@
1
+ # Generated by Plain 0.52.2 on 2025-07-02 18:16
2
+
3
+ import plain.models.deletion
4
+ from plain import models
5
+ from plain.models import migrations
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+ dependencies = [
10
+ ("examples", "0002_test_field_removed"),
11
+ ]
12
+
13
+ operations = [
14
+ migrations.CreateModel(
15
+ name="DeleteParent",
16
+ fields=[
17
+ ("id", models.BigAutoField(auto_created=True, primary_key=True)),
18
+ ("name", models.CharField(max_length=100)),
19
+ ],
20
+ ),
21
+ migrations.CreateModel(
22
+ name="ChildSetNull",
23
+ fields=[
24
+ ("id", models.BigAutoField(auto_created=True, primary_key=True)),
25
+ (
26
+ "parent",
27
+ models.ForeignKey(
28
+ allow_null=True,
29
+ on_delete=plain.models.deletion.SET_NULL,
30
+ to="examples.deleteparent",
31
+ ),
32
+ ),
33
+ ],
34
+ ),
35
+ migrations.CreateModel(
36
+ name="ChildSetDefault",
37
+ fields=[
38
+ ("id", models.BigAutoField(auto_created=True, primary_key=True)),
39
+ (
40
+ "parent",
41
+ models.ForeignKey(
42
+ default=1,
43
+ on_delete=plain.models.deletion.SET_DEFAULT,
44
+ to="examples.deleteparent",
45
+ ),
46
+ ),
47
+ ],
48
+ ),
49
+ migrations.CreateModel(
50
+ name="ChildDoNothing",
51
+ fields=[
52
+ ("id", models.BigAutoField(auto_created=True, primary_key=True)),
53
+ (
54
+ "parent",
55
+ models.ForeignKey(
56
+ on_delete=plain.models.deletion.DO_NOTHING,
57
+ to="examples.deleteparent",
58
+ ),
59
+ ),
60
+ ],
61
+ ),
62
+ migrations.CreateModel(
63
+ name="ChildCascade",
64
+ fields=[
65
+ ("id", models.BigAutoField(auto_created=True, primary_key=True)),
66
+ (
67
+ "parent",
68
+ models.ForeignKey(
69
+ on_delete=plain.models.deletion.CASCADE,
70
+ to="examples.deleteparent",
71
+ ),
72
+ ),
73
+ ],
74
+ ),
75
+ migrations.CreateModel(
76
+ name="ChildRestrict",
77
+ fields=[
78
+ ("id", models.BigAutoField(auto_created=True, primary_key=True)),
79
+ (
80
+ "parent",
81
+ models.ForeignKey(
82
+ on_delete=plain.models.deletion.RESTRICT,
83
+ to="examples.deleteparent",
84
+ ),
85
+ ),
86
+ ],
87
+ ),
88
+ migrations.CreateModel(
89
+ name="ChildProtect",
90
+ fields=[
91
+ ("id", models.BigAutoField(auto_created=True, primary_key=True)),
92
+ (
93
+ "parent",
94
+ models.ForeignKey(
95
+ on_delete=plain.models.deletion.PROTECT,
96
+ to="examples.deleteparent",
97
+ ),
98
+ ),
99
+ ],
100
+ ),
101
+ ]
@@ -0,0 +1,62 @@
1
+ from plain import models
2
+
3
+
4
+ @models.register_model
5
+ class Car(models.Model):
6
+ make = models.CharField(max_length=100)
7
+ model = models.CharField(max_length=100)
8
+
9
+ class Meta:
10
+ constraints = [
11
+ models.UniqueConstraint(fields=["make", "model"], name="unique_make_model"),
12
+ ]
13
+
14
+
15
+ class UnregisteredModel(models.Model):
16
+ pass
17
+
18
+
19
+ @models.register_model
20
+ class DeleteParent(models.Model):
21
+ name = models.CharField(max_length=100)
22
+
23
+
24
+ @models.register_model
25
+ class ChildCascade(models.Model):
26
+ parent = models.ForeignKey(DeleteParent, on_delete=models.CASCADE)
27
+
28
+
29
+ @models.register_model
30
+ class ChildProtect(models.Model):
31
+ parent = models.ForeignKey(DeleteParent, on_delete=models.PROTECT)
32
+
33
+
34
+ @models.register_model
35
+ class ChildRestrict(models.Model):
36
+ parent = models.ForeignKey(DeleteParent, on_delete=models.RESTRICT)
37
+
38
+
39
+ @models.register_model
40
+ class ChildSetNull(models.Model):
41
+ parent = models.ForeignKey(
42
+ DeleteParent,
43
+ on_delete=models.SET_NULL,
44
+ allow_null=True,
45
+ )
46
+
47
+
48
+ @models.register_model
49
+ class ChildSetDefault(models.Model):
50
+ def default_parent_pk():
51
+ return DeleteParent.objects.get(name="default").pk
52
+
53
+ parent = models.ForeignKey(
54
+ DeleteParent,
55
+ on_delete=models.SET_DEFAULT,
56
+ default=default_parent_pk,
57
+ )
58
+
59
+
60
+ @models.register_model
61
+ class ChildDoNothing(models.Model):
62
+ parent = models.ForeignKey(DeleteParent, on_delete=models.DO_NOTHING)
@@ -4,7 +4,3 @@ INSTALLED_PACKAGES = [
4
4
  "plain.models",
5
5
  "app.examples",
6
6
  ]
7
- DATABASE = {
8
- "ENGINE": "plain.models.backends.sqlite3",
9
- "NAME": ":memory:",
10
- }
@@ -0,0 +1,73 @@
1
+ import pytest
2
+ from app.examples.models import (
3
+ ChildCascade,
4
+ ChildProtect,
5
+ ChildRestrict,
6
+ ChildSetDefault,
7
+ ChildSetNull,
8
+ DeleteParent,
9
+ )
10
+
11
+ from plain.models import (
12
+ ProtectedError,
13
+ RestrictedError,
14
+ )
15
+
16
+
17
+ def _create_parents():
18
+ default_parent = DeleteParent.objects.create(name="default")
19
+ parent = DeleteParent.objects.create(name="parent")
20
+ return default_parent, parent
21
+
22
+
23
+ def test_cascade_delete(db):
24
+ _create_parents()
25
+ parent = DeleteParent.objects.get(name="parent")
26
+ ChildCascade.objects.create(parent=parent)
27
+ parent.delete()
28
+ assert ChildCascade.objects.count() == 0
29
+
30
+
31
+ def test_protect_delete(db):
32
+ _create_parents()
33
+ parent = DeleteParent.objects.get(name="parent")
34
+ ChildProtect.objects.create(parent=parent)
35
+ with pytest.raises(ProtectedError):
36
+ parent.delete()
37
+ assert DeleteParent.objects.filter(pk=parent.pk).exists()
38
+
39
+
40
+ def test_restrict_delete(db):
41
+ _create_parents()
42
+ parent = DeleteParent.objects.get(name="parent")
43
+ ChildRestrict.objects.create(parent=parent)
44
+ with pytest.raises(RestrictedError):
45
+ parent.delete()
46
+ assert DeleteParent.objects.filter(pk=parent.pk).exists()
47
+
48
+
49
+ def test_set_null_delete(db):
50
+ _create_parents()
51
+ parent = DeleteParent.objects.get(name="parent")
52
+ child = ChildSetNull.objects.create(parent=parent)
53
+ parent.delete()
54
+ child.refresh_from_db()
55
+ assert child.parent_id is None
56
+
57
+
58
+ def test_set_default_delete(db):
59
+ default_parent, parent = _create_parents()
60
+ child = ChildSetDefault.objects.create(parent=parent)
61
+ parent.delete()
62
+ child.refresh_from_db()
63
+ assert child.parent_id == default_parent.pk
64
+
65
+
66
+ # def test_do_nothing_delete(db):
67
+ # default_parent, parent = _create_parents()
68
+ # child = ChildDoNothing.objects.create(parent=parent)
69
+ # parent.delete()
70
+ # with pytest.raises(IntegrityError):
71
+ # db_connection.check_constraints()
72
+ # child.parent = default_parent
73
+ # child.save(clean_and_validate=False)
@@ -1,16 +0,0 @@
1
- from plain import models
2
-
3
-
4
- @models.register_model
5
- class Car(models.Model):
6
- make = models.CharField(max_length=100)
7
- model = models.CharField(max_length=100)
8
-
9
- class Meta:
10
- constraints = [
11
- models.UniqueConstraint(fields=["make", "model"], name="unique_make_model"),
12
- ]
13
-
14
-
15
- class UnregisteredModel(models.Model):
16
- pass
File without changes
File without changes