plain.postgres 0.89.2__tar.gz → 0.90.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 (125) hide show
  1. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/PKG-INFO +16 -21
  2. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/CHANGELOG.md +17 -0
  3. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/README.md +15 -20
  4. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/__init__.py +0 -2
  5. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +1 -1
  6. plain_postgres-0.90.0/plain/postgres/cli/converge.py +80 -0
  7. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/cli/core.py +2 -0
  8. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/connection.py +2 -6
  9. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/dialect.py +14 -96
  10. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/expressions.py +17 -19
  11. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/fields/__init__.py +58 -155
  12. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/fields/encrypted.py +2 -8
  13. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/fields/json.py +1 -3
  14. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/fields/reverse_related.py +0 -3
  15. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/fields/timezones.py +5 -2
  16. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/forms.py +3 -12
  17. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/functions/text.py +5 -5
  18. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/lookups.py +2 -5
  19. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/migrations/recorder.py +2 -2
  20. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/schema.py +24 -27
  21. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/types.py +0 -2
  22. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/types.pyi +1 -24
  23. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/pyproject.toml +1 -1
  24. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/tests/app/examples/migrations/0001_initial.py +2 -2
  25. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +1 -1
  26. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +4 -4
  27. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +1 -1
  28. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/tests/app/examples/migrations/0006_secretstore.py +1 -1
  29. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/tests/app/examples/models.py +9 -9
  30. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/tests/test_manager_assignment.py +3 -3
  31. plain_postgres-0.90.0/tests/test_schema_normalize_type.py +94 -0
  32. plain_postgres-0.89.2/tests/test_schema_normalize_type.py +0 -93
  33. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/.gitignore +0 -0
  34. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/CLAUDE.md +0 -0
  35. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/LICENSE +0 -0
  36. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/README.md +0 -0
  37. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
  38. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/aggregates.py +0 -0
  39. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/backups/__init__.py +0 -0
  40. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/backups/cli.py +0 -0
  41. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/backups/clients.py +0 -0
  42. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/backups/core.py +0 -0
  43. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/base.py +0 -0
  44. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/cli/__init__.py +0 -0
  45. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/cli/diagnose.py +0 -0
  46. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/cli/migrations.py +0 -0
  47. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/cli/schema.py +0 -0
  48. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/config.py +0 -0
  49. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/connections.py +0 -0
  50. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/constants.py +0 -0
  51. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/constraints.py +0 -0
  52. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/database_url.py +0 -0
  53. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/db.py +0 -0
  54. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/default_settings.py +0 -0
  55. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/deletion.py +0 -0
  56. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/entrypoints.py +0 -0
  57. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/enums.py +0 -0
  58. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/exceptions.py +0 -0
  59. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/fields/mixins.py +0 -0
  60. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/fields/related.py +0 -0
  61. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/fields/related_descriptors.py +0 -0
  62. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/fields/related_lookups.py +0 -0
  63. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/fields/related_managers.py +0 -0
  64. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
  65. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/functions/__init__.py +0 -0
  66. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/functions/comparison.py +0 -0
  67. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/functions/datetime.py +0 -0
  68. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/functions/math.py +0 -0
  69. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/functions/mixins.py +0 -0
  70. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/functions/window.py +0 -0
  71. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/indexes.py +0 -0
  72. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/introspection/__init__.py +0 -0
  73. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/introspection/health.py +0 -0
  74. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/introspection/schema.py +0 -0
  75. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/meta.py +0 -0
  76. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/migrations/__init__.py +0 -0
  77. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/migrations/autodetector.py +0 -0
  78. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/migrations/exceptions.py +0 -0
  79. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/migrations/executor.py +0 -0
  80. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/migrations/graph.py +0 -0
  81. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/migrations/loader.py +0 -0
  82. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/migrations/migration.py +0 -0
  83. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/migrations/operations/__init__.py +0 -0
  84. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/migrations/operations/base.py +0 -0
  85. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/migrations/operations/fields.py +0 -0
  86. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/migrations/operations/models.py +0 -0
  87. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/migrations/operations/special.py +0 -0
  88. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/migrations/optimizer.py +0 -0
  89. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/migrations/questioner.py +0 -0
  90. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/migrations/serializer.py +0 -0
  91. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/migrations/state.py +0 -0
  92. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/migrations/utils.py +0 -0
  93. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/migrations/writer.py +0 -0
  94. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/options.py +0 -0
  95. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/otel.py +0 -0
  96. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/preflight.py +0 -0
  97. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/query.py +0 -0
  98. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/query_utils.py +0 -0
  99. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/registry.py +0 -0
  100. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/sql/__init__.py +0 -0
  101. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/sql/compiler.py +0 -0
  102. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/sql/constants.py +0 -0
  103. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/sql/datastructures.py +0 -0
  104. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/sql/query.py +0 -0
  105. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/sql/where.py +0 -0
  106. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/test/__init__.py +0 -0
  107. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/test/pytest.py +0 -0
  108. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/test/utils.py +0 -0
  109. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/transaction.py +0 -0
  110. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/plain/postgres/utils.py +0 -0
  111. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
  112. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/tests/app/examples/migrations/__init__.py +0 -0
  113. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/tests/app/settings.py +0 -0
  114. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/tests/app/urls.py +0 -0
  115. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/tests/test_connection_isolation.py +0 -0
  116. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/tests/test_connection_lifecycle.py +0 -0
  117. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/tests/test_database_url.py +0 -0
  118. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/tests/test_delete_behaviors.py +0 -0
  119. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/tests/test_encrypted_fields.py +0 -0
  120. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/tests/test_exceptions.py +0 -0
  121. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/tests/test_iterator.py +0 -0
  122. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/tests/test_models.py +0 -0
  123. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/tests/test_read_only_transactions.py +0 -0
  124. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/tests/test_related_descriptors.py +0 -0
  125. {plain_postgres-0.89.2 → plain_postgres-0.90.0}/tests/test_related_manager_api.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain.postgres
3
- Version: 0.89.2
3
+ Version: 0.90.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-Expression: BSD-3-Clause
@@ -154,8 +154,8 @@ class PublishedQuerySet(postgres.QuerySet["Article"]):
154
154
 
155
155
  @postgres.register_model
156
156
  class Article(postgres.Model):
157
- title: str = types.CharField(max_length=200)
158
- status: str = types.CharField(max_length=20)
157
+ title: str = types.TextField(max_length=200)
158
+ status: str = types.TextField(max_length=20)
159
159
 
160
160
  query = PublishedQuerySet()
161
161
 
@@ -552,7 +552,7 @@ from plain.postgres import types
552
552
 
553
553
  class Product(postgres.Model):
554
554
  # Text fields
555
- name: str = types.CharField(max_length=200)
555
+ name: str = types.TextField(max_length=200)
556
556
  description: str = types.TextField()
557
557
 
558
558
  # Numeric fields
@@ -569,8 +569,7 @@ class Product(postgres.Model):
569
569
 
570
570
  **Text fields:**
571
571
 
572
- - [`CharField`](./fields/__init__.py#CharField) - String with max length
573
- - [`TextField`](./fields/__init__.py#TextField) - Unlimited text
572
+ - [`TextField`](./fields/__init__.py#TextField) - Text (with optional max length)
574
573
  - [`EmailField`](./fields/__init__.py#EmailField) - Email address (validated)
575
574
  - [`URLField`](./fields/__init__.py#URLField) - URL (validated)
576
575
 
@@ -659,7 +658,7 @@ from plain.postgres import types
659
658
 
660
659
  @postgres.register_model
661
660
  class Integration(postgres.Model):
662
- name: str = types.CharField(max_length=100)
661
+ name: str = types.TextField(max_length=100)
663
662
  api_key: str = types.EncryptedTextField(max_length=200)
664
663
  credentials: dict = types.EncryptedJSONField(required=False, allow_null=True)
665
664
  ```
@@ -694,7 +693,7 @@ from plain.postgres import types
694
693
 
695
694
  @postgres.register_model
696
695
  class Book(postgres.Model):
697
- title: str = types.CharField(max_length=200)
696
+ title: str = types.TextField(max_length=200)
698
697
  author: Author = types.ForeignKeyField("Author", on_delete=postgres.CASCADE)
699
698
  tags = types.ManyToManyField("Tag")
700
699
  ```
@@ -709,13 +708,13 @@ from plain.postgres import types
709
708
 
710
709
  @postgres.register_model
711
710
  class Author(postgres.Model):
712
- name: str = types.CharField(max_length=200)
711
+ name: str = types.TextField(max_length=200)
713
712
  # Explicit reverse accessor for all books by this author
714
713
  books = types.ReverseForeignKey(to="Book", field="author")
715
714
 
716
715
  @postgres.register_model
717
716
  class Book(postgres.Model):
718
- title: str = types.CharField(max_length=200)
717
+ title: str = types.TextField(max_length=200)
719
718
  author: Author = types.ForeignKeyField(Author, on_delete=postgres.CASCADE)
720
719
 
721
720
  # Usage
@@ -732,13 +731,13 @@ For many-to-many relationships:
732
731
  ```python
733
732
  @postgres.register_model
734
733
  class Feature(postgres.Model):
735
- name: str = types.CharField(max_length=100)
734
+ name: str = types.TextField(max_length=100)
736
735
  # Explicit reverse accessor for all cars with this feature
737
736
  cars = types.ReverseManyToMany(to="Car", field="features")
738
737
 
739
738
  @postgres.register_model
740
739
  class Car(postgres.Model):
741
- model: str = types.CharField(max_length=100)
740
+ model: str = types.TextField(max_length=100)
742
741
  features = types.ManyToManyField(Feature)
743
742
 
744
743
  # Usage
@@ -805,7 +804,7 @@ You can optimize queries and ensure data integrity with indexes and constraints:
805
804
  ```python
806
805
  class User(postgres.Model):
807
806
  email: str = types.EmailField()
808
- username: str = types.CharField(max_length=150)
807
+ username: str = types.TextField(max_length=150)
809
808
  age: int = types.IntegerField()
810
809
 
811
810
  model_options = postgres.Options(
@@ -829,12 +828,12 @@ Add indexes for columns that appear in `.filter()`, `.order_by()`, or `.exclude(
829
828
  ```python
830
829
  # Bad — full table scan on every filtered query
831
830
  class Order(postgres.Model):
832
- status: str = types.CharField(max_length=20)
831
+ status: str = types.TextField(max_length=20)
833
832
  created_at: datetime = types.DateTimeField()
834
833
 
835
834
  # Good — indexed for common queries
836
835
  class Order(postgres.Model):
837
- status: str = types.CharField(max_length=20)
836
+ status: str = types.TextField(max_length=20)
838
837
  created_at: datetime = types.DateTimeField()
839
838
 
840
839
  model_options = postgres.Options(
@@ -876,10 +875,10 @@ Use `default=""` instead of `allow_null=True` to avoid two representations of "e
876
875
 
877
876
  ```python
878
877
  # Bad — NULL and "" both mean "empty"
879
- nickname: str = types.CharField(max_length=50, allow_null=True)
878
+ nickname: str = types.TextField(max_length=50, allow_null=True)
880
879
 
881
880
  # Good — single empty representation
882
- nickname: str = types.CharField(max_length=50, default="")
881
+ nickname: str = types.TextField(max_length=50, default="")
883
882
  ```
884
883
 
885
884
  ## Forms
@@ -1041,10 +1040,6 @@ See [`default_settings.py`](./default_settings.py) for more details.
1041
1040
 
1042
1041
  Add the field to your model class, then run `plain makemigrations` to create a migration. If the field is required (no default value and not nullable), you'll be prompted to provide a default value for existing rows.
1043
1042
 
1044
- #### What's the difference between `CharField` and `TextField`?
1045
-
1046
- `CharField` requires a `max_length` and is typically used for short strings like names or emails. `TextField` has no length limit and is used for longer content like descriptions or body text.
1047
-
1048
1043
  #### How do I create a unique constraint on multiple fields?
1049
1044
 
1050
1045
  Use `UniqueConstraint` in your model's `model_options`:
@@ -1,5 +1,22 @@
1
1
  # plain-postgres changelog
2
2
 
3
+ ## [0.90.0](https://github.com/dropseed/plain/releases/plain-postgres@0.90.0) (2026-03-28)
4
+
5
+ ### What's changed
6
+
7
+ - **Removed `CharField`** — use `TextField` for all string fields. PostgreSQL treats `varchar` and `text` identically (same storage, same performance), so the distinction was unnecessary. `TextField` now accepts an optional `max_length` for Python-side validation via `MaxLengthValidator`, without affecting the database column type. ([5062ee4dd1fd](https://github.com/dropseed/plain/commit/5062ee4dd1fd))
8
+ - **`EmailField` and `URLField` now extend `TextField`** instead of `CharField`. Their default `max_length` values (254 and 200 respectively) have been removed — pass `max_length` explicitly if you need validation. ([5062ee4dd1fd](https://github.com/dropseed/plain/commit/5062ee4dd1fd))
9
+ - **Simplified field class internals** — removed the `get_internal_type()` method and 6 lookup dicts from `dialect.py`. Each field class now declares its SQL type directly via `db_type_sql` class attribute. String-based type comparisons replaced with `isinstance()` checks throughout. ([3ffdebe22250](https://github.com/dropseed/plain/commit/3ffdebe22250))
10
+ - **Added `postgres converge` command** — detects and fixes safe schema mismatches between models and the database. Currently handles `character varying` → `text` conversions. ([fe8cf3995e95](https://github.com/dropseed/plain/commit/fe8cf3995e95))
11
+
12
+ ### Upgrade instructions
13
+
14
+ - Replace `CharField` with `TextField` in model code (e.g. `types.CharField(max_length=100)` → `types.TextField(max_length=100)`)
15
+ - Replace `CharField` with `TextField` in migration files (e.g. `postgres.CharField(max_length=255)` → `postgres.TextField(max_length=255)`)
16
+ - If you subclass `CharField`, change the parent class to `TextField`
17
+ - `EmailField` no longer defaults `max_length=254` and `URLField` no longer defaults `max_length=200` — remove these from migration files if present (e.g. `postgres.EmailField(max_length=254)` → `postgres.EmailField()`)
18
+ - Run `plain postgres converge` to convert existing `character varying` columns to `text` (in development and production). The conversion is instant and safe — PostgreSQL treats them identically. Use `--yes` to skip confirmation in CI/deploy scripts.
19
+
3
20
  ## [0.89.2](https://github.com/dropseed/plain/releases/plain-postgres@0.89.2) (2026-03-27)
4
21
 
5
22
  ### What's changed
@@ -142,8 +142,8 @@ class PublishedQuerySet(postgres.QuerySet["Article"]):
142
142
 
143
143
  @postgres.register_model
144
144
  class Article(postgres.Model):
145
- title: str = types.CharField(max_length=200)
146
- status: str = types.CharField(max_length=20)
145
+ title: str = types.TextField(max_length=200)
146
+ status: str = types.TextField(max_length=20)
147
147
 
148
148
  query = PublishedQuerySet()
149
149
 
@@ -540,7 +540,7 @@ from plain.postgres import types
540
540
 
541
541
  class Product(postgres.Model):
542
542
  # Text fields
543
- name: str = types.CharField(max_length=200)
543
+ name: str = types.TextField(max_length=200)
544
544
  description: str = types.TextField()
545
545
 
546
546
  # Numeric fields
@@ -557,8 +557,7 @@ class Product(postgres.Model):
557
557
 
558
558
  **Text fields:**
559
559
 
560
- - [`CharField`](./fields/__init__.py#CharField) - String with max length
561
- - [`TextField`](./fields/__init__.py#TextField) - Unlimited text
560
+ - [`TextField`](./fields/__init__.py#TextField) - Text (with optional max length)
562
561
  - [`EmailField`](./fields/__init__.py#EmailField) - Email address (validated)
563
562
  - [`URLField`](./fields/__init__.py#URLField) - URL (validated)
564
563
 
@@ -647,7 +646,7 @@ from plain.postgres import types
647
646
 
648
647
  @postgres.register_model
649
648
  class Integration(postgres.Model):
650
- name: str = types.CharField(max_length=100)
649
+ name: str = types.TextField(max_length=100)
651
650
  api_key: str = types.EncryptedTextField(max_length=200)
652
651
  credentials: dict = types.EncryptedJSONField(required=False, allow_null=True)
653
652
  ```
@@ -682,7 +681,7 @@ from plain.postgres import types
682
681
 
683
682
  @postgres.register_model
684
683
  class Book(postgres.Model):
685
- title: str = types.CharField(max_length=200)
684
+ title: str = types.TextField(max_length=200)
686
685
  author: Author = types.ForeignKeyField("Author", on_delete=postgres.CASCADE)
687
686
  tags = types.ManyToManyField("Tag")
688
687
  ```
@@ -697,13 +696,13 @@ from plain.postgres import types
697
696
 
698
697
  @postgres.register_model
699
698
  class Author(postgres.Model):
700
- name: str = types.CharField(max_length=200)
699
+ name: str = types.TextField(max_length=200)
701
700
  # Explicit reverse accessor for all books by this author
702
701
  books = types.ReverseForeignKey(to="Book", field="author")
703
702
 
704
703
  @postgres.register_model
705
704
  class Book(postgres.Model):
706
- title: str = types.CharField(max_length=200)
705
+ title: str = types.TextField(max_length=200)
707
706
  author: Author = types.ForeignKeyField(Author, on_delete=postgres.CASCADE)
708
707
 
709
708
  # Usage
@@ -720,13 +719,13 @@ For many-to-many relationships:
720
719
  ```python
721
720
  @postgres.register_model
722
721
  class Feature(postgres.Model):
723
- name: str = types.CharField(max_length=100)
722
+ name: str = types.TextField(max_length=100)
724
723
  # Explicit reverse accessor for all cars with this feature
725
724
  cars = types.ReverseManyToMany(to="Car", field="features")
726
725
 
727
726
  @postgres.register_model
728
727
  class Car(postgres.Model):
729
- model: str = types.CharField(max_length=100)
728
+ model: str = types.TextField(max_length=100)
730
729
  features = types.ManyToManyField(Feature)
731
730
 
732
731
  # Usage
@@ -793,7 +792,7 @@ You can optimize queries and ensure data integrity with indexes and constraints:
793
792
  ```python
794
793
  class User(postgres.Model):
795
794
  email: str = types.EmailField()
796
- username: str = types.CharField(max_length=150)
795
+ username: str = types.TextField(max_length=150)
797
796
  age: int = types.IntegerField()
798
797
 
799
798
  model_options = postgres.Options(
@@ -817,12 +816,12 @@ Add indexes for columns that appear in `.filter()`, `.order_by()`, or `.exclude(
817
816
  ```python
818
817
  # Bad — full table scan on every filtered query
819
818
  class Order(postgres.Model):
820
- status: str = types.CharField(max_length=20)
819
+ status: str = types.TextField(max_length=20)
821
820
  created_at: datetime = types.DateTimeField()
822
821
 
823
822
  # Good — indexed for common queries
824
823
  class Order(postgres.Model):
825
- status: str = types.CharField(max_length=20)
824
+ status: str = types.TextField(max_length=20)
826
825
  created_at: datetime = types.DateTimeField()
827
826
 
828
827
  model_options = postgres.Options(
@@ -864,10 +863,10 @@ Use `default=""` instead of `allow_null=True` to avoid two representations of "e
864
863
 
865
864
  ```python
866
865
  # Bad — NULL and "" both mean "empty"
867
- nickname: str = types.CharField(max_length=50, allow_null=True)
866
+ nickname: str = types.TextField(max_length=50, allow_null=True)
868
867
 
869
868
  # Good — single empty representation
870
- nickname: str = types.CharField(max_length=50, default="")
869
+ nickname: str = types.TextField(max_length=50, default="")
871
870
  ```
872
871
 
873
872
  ## Forms
@@ -1029,10 +1028,6 @@ See [`default_settings.py`](./default_settings.py) for more details.
1029
1028
 
1030
1029
  Add the field to your model class, then run `plain makemigrations` to create a migration. If the field is required (no default value and not nullable), you'll be prompted to provide a default value for existing rows.
1031
1030
 
1032
- #### What's the difference between `CharField` and `TextField`?
1033
-
1034
- `CharField` requires a `max_length` and is typically used for short strings like names or emails. `TextField` has no length limit and is used for longer content like descriptions or body text.
1035
-
1036
1031
  #### How do I create a unique constraint on multiple fields?
1037
1032
 
1038
1033
  Use `UniqueConstraint` in your model's `model_options`:
@@ -13,7 +13,6 @@ from .fields import (
13
13
  BigIntegerField,
14
14
  BinaryField,
15
15
  BooleanField,
16
- CharField,
17
16
  DateField,
18
17
  DateTimeField,
19
18
  DecimalField,
@@ -63,7 +62,6 @@ __all__ = [
63
62
  "BigIntegerField",
64
63
  "BinaryField",
65
64
  "BooleanField",
66
- "CharField",
67
65
  "DateField",
68
66
  "DateTimeField",
69
67
  "DecimalField",
@@ -12,7 +12,7 @@ Import fields via `from plain.postgres import types` and annotate with Python ty
12
12
  ```python
13
13
  from plain.postgres import types
14
14
 
15
- name: str = types.CharField(max_length=100)
15
+ name: str = types.TextField(max_length=100)
16
16
  car: Car = types.ForeignKeyField("Car", on_delete=postgres.CASCADE)
17
17
  ```
18
18
 
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ import click
4
+
5
+ from ..db import get_connection
6
+ from ..dialect import quote_name
7
+ from ..introspection import check_model
8
+ from ..registry import models_registry
9
+
10
+
11
+ @click.command()
12
+ @click.option(
13
+ "--yes",
14
+ "-y",
15
+ is_flag=True,
16
+ help="Skip confirmation prompt.",
17
+ )
18
+ def converge(yes: bool) -> None:
19
+ """Fix safe schema mismatches between models and the database.
20
+
21
+ Detects column type mismatches that can be fixed without data loss
22
+ (e.g. character varying → text) and applies ALTER COLUMN TYPE statements.
23
+ """
24
+ conn = get_connection()
25
+ fixes: list[tuple[str, str, str, str]] = [] # (table, column, actual, expected)
26
+
27
+ with conn.cursor() as cursor:
28
+ for model in models_registry.get_models():
29
+ result = check_model(conn, cursor, model)
30
+ table = result["table"]
31
+ for col in result["columns"]:
32
+ for issue in col["issues"]:
33
+ if issue["kind"] == "type_mismatch":
34
+ expected = issue["detail"].split("expected ")[1].split(",")[0]
35
+ actual = issue["detail"].split("actual ")[1]
36
+ if _is_safe_type_fix(actual, expected):
37
+ fixes.append((table, col["name"], actual, expected))
38
+
39
+ if not fixes:
40
+ click.secho("Schema is converged — nothing to fix.", fg="green")
41
+ return
42
+
43
+ click.secho(
44
+ f"{len(fixes)} column{'s' if len(fixes) != 1 else ''} to fix:\n", bold=True
45
+ )
46
+ for table, column, actual, expected in fixes:
47
+ click.echo(f" {table}.{column}: ", nl=False)
48
+ click.secho(actual, fg="red", nl=False)
49
+ click.echo(" → ", nl=False)
50
+ click.secho(expected, fg="green")
51
+
52
+ click.echo()
53
+
54
+ if not yes:
55
+ if not click.confirm("Apply these changes?"):
56
+ return
57
+
58
+ click.echo()
59
+
60
+ with conn.cursor() as cursor:
61
+ for table, column, actual, expected in fixes:
62
+ sql = f"ALTER TABLE {quote_name(table)} ALTER COLUMN {quote_name(column)} TYPE {expected}"
63
+ click.echo(f" {sql}")
64
+ cursor.execute(sql)
65
+
66
+ conn.commit()
67
+
68
+ click.echo()
69
+ click.secho(
70
+ f"Fixed {len(fixes)} column{'s' if len(fixes) != 1 else ''}.", fg="green"
71
+ )
72
+
73
+
74
+ def _is_safe_type_fix(actual: str, expected: str) -> bool:
75
+ """Return True if converting actual → expected is safe (no data loss, no rewrite)."""
76
+ # character varying(N) → text is always safe in PostgreSQL.
77
+ # They use the same storage format; the ALTER is metadata-only.
78
+ if actual.startswith("character varying") and expected == "text":
79
+ return True
80
+ return False
@@ -13,6 +13,7 @@ from plain.cli import register_cli
13
13
  from ..backups.cli import cli as backups_cli
14
14
  from ..db import get_connection
15
15
  from ..dialect import quote_name
16
+ from .converge import converge
16
17
  from .diagnose import diagnose
17
18
  from .schema import schema
18
19
 
@@ -24,6 +25,7 @@ def cli() -> None:
24
25
 
25
26
 
26
27
  cli.add_command(backups_cli)
28
+ cli.add_command(converge)
27
29
  cli.add_command(diagnose)
28
30
  cli.add_command(schema)
29
31
 
@@ -29,6 +29,7 @@ from plain.exceptions import ImproperlyConfigured
29
29
  from plain.logs import get_framework_logger
30
30
  from plain.postgres import utils
31
31
  from plain.postgres.dialect import MAX_NAME_LENGTH, quote_name
32
+ from plain.postgres.fields import GenericIPAddressField, TimeField, UUIDField
32
33
  from plain.postgres.indexes import Index
33
34
  from plain.postgres.schema import DatabaseSchemaEditor
34
35
  from plain.postgres.transaction import TransactionManagementError
@@ -841,12 +842,7 @@ class DatabaseConnection:
841
842
  to that type. The resulting string should contain a '%s' placeholder
842
843
  for the expression being cast.
843
844
  """
844
- internal_type = output_field.get_internal_type()
845
- if internal_type in (
846
- "GenericIPAddressField",
847
- "TimeField",
848
- "UUIDField",
849
- ):
845
+ if isinstance(output_field, GenericIPAddressField | TimeField | UUIDField):
850
846
  # PostgreSQL will resolve a union as type 'text' if input types are
851
847
  # 'unknown'.
852
848
  # https://www.postgresql.org/docs/current/typeconv-union-case.html
@@ -14,7 +14,6 @@ from functools import lru_cache, partial
14
14
  from typing import TYPE_CHECKING, Any
15
15
 
16
16
  import psycopg
17
- from psycopg.types import numeric
18
17
  from psycopg.types.json import Jsonb
19
18
 
20
19
  from plain.postgres.constants import OnConflict
@@ -25,26 +24,6 @@ from plain.utils.regex_helper import _lazy_re_compile
25
24
  if TYPE_CHECKING:
26
25
  from plain.postgres.fields import Field
27
26
 
28
-
29
- # Integer field safe ranges by internal_type.
30
- INTEGER_FIELD_RANGES: dict[str, tuple[int, int]] = {
31
- "SmallIntegerField": (-32768, 32767),
32
- "IntegerField": (-2147483648, 2147483647),
33
- "BigIntegerField": (-9223372036854775808, 9223372036854775807),
34
- "PositiveBigIntegerField": (0, 9223372036854775807),
35
- "PositiveSmallIntegerField": (0, 32767),
36
- "PositiveIntegerField": (0, 2147483647),
37
- "PrimaryKeyField": (-9223372036854775808, 9223372036854775807),
38
- }
39
-
40
- # Mapping of Field.get_internal_type() to the data type for Cast().
41
- CAST_DATA_TYPES: dict[str, str] = {
42
- "PrimaryKeyField": "bigint",
43
- }
44
-
45
- # CharField data type when max_length isn't provided.
46
- CAST_CHAR_FIELD_WITHOUT_MAX_LENGTH: str | None = "character varying"
47
-
48
27
  # Start and end points for window expressions.
49
28
  PRECEDING: str = "PRECEDING"
50
29
  FOLLOWING: str = "FOLLOWING"
@@ -68,16 +47,6 @@ EXPLAIN_OPTIONS = frozenset(
68
47
  )
69
48
  SUPPORTED_EXPLAIN_FORMATS: set[str] = {"JSON", "TEXT", "XML", "YAML"}
70
49
 
71
- # PostgreSQL integer type mapping for psycopg.
72
- INTEGERFIELD_TYPE_MAP = {
73
- "SmallIntegerField": numeric.Int2,
74
- "IntegerField": numeric.Int4,
75
- "BigIntegerField": numeric.Int8,
76
- "PositiveSmallIntegerField": numeric.Int2,
77
- "PositiveIntegerField": numeric.Int4,
78
- "PositiveBigIntegerField": numeric.Int8,
79
- }
80
-
81
50
  # Maximum length of an identifier (63 by default in PostgreSQL).
82
51
  MAX_NAME_LENGTH: int = 63
83
52
 
@@ -91,52 +60,6 @@ DEFERRABLE_SQL: str = " DEFERRABLE INITIALLY DEFERRED"
91
60
  _EXTRACT_FORMAT_RE = _lazy_re_compile(r"[A-Z_]+")
92
61
 
93
62
 
94
- # ##### Data type mappings (from constants.py) #####
95
-
96
-
97
- def _get_varchar_column(data: dict[str, Any]) -> str:
98
- if data["max_length"] is None:
99
- return "character varying"
100
- return "character varying({max_length})".format(**data)
101
-
102
-
103
- # Maps Field objects to their associated PostgreSQL column types.
104
- # Column-type strings can contain format strings interpolated against Field.__dict__.
105
- DATA_TYPES: dict[str, Any] = {
106
- "PrimaryKeyField": "bigint",
107
- "BinaryField": "bytea",
108
- "BooleanField": "boolean",
109
- "CharField": _get_varchar_column,
110
- "DateField": "date",
111
- "DateTimeField": "timestamp with time zone",
112
- "DecimalField": "numeric(%(max_digits)s,%(decimal_places)s)",
113
- "DurationField": "interval",
114
- "FloatField": "double precision",
115
- "IntegerField": "integer",
116
- "BigIntegerField": "bigint",
117
- "GenericIPAddressField": "inet",
118
- "JSONField": "jsonb",
119
- "PositiveBigIntegerField": "bigint",
120
- "PositiveIntegerField": "integer",
121
- "PositiveSmallIntegerField": "smallint",
122
- "SmallIntegerField": "smallint",
123
- "TextField": "text",
124
- "TimeField": "time without time zone",
125
- "UUIDField": "uuid",
126
- }
127
-
128
- # Check constraints for fields that need them.
129
- DATA_TYPE_CHECK_CONSTRAINTS: dict[str, str] = {
130
- "PositiveBigIntegerField": '"%(column)s" >= 0',
131
- "PositiveIntegerField": '"%(column)s" >= 0',
132
- "PositiveSmallIntegerField": '"%(column)s" >= 0',
133
- }
134
-
135
- # Suffix applied to column definitions (e.g., for identity columns).
136
- DATA_TYPES_SUFFIX: dict[str, str] = {
137
- "PrimaryKeyField": "GENERATED BY DEFAULT AS IDENTITY",
138
- }
139
-
140
63
  # SQL operators for lookups.
141
64
  OPERATORS: dict[str, str] = {
142
65
  "exact": "= %s",
@@ -375,19 +298,21 @@ def limit_offset_sql(low_mark: int | None, high_mark: int | None) -> str:
375
298
  )
376
299
 
377
300
 
378
- def lookup_cast(lookup_type: str, internal_type: str | None = None) -> str:
301
+ def lookup_cast(lookup_type: str, field: Field | None = None) -> str:
379
302
  """
380
303
  Return the string to use in a query when performing lookups
381
304
  ("contains", "like", etc.). It should contain a '%s' placeholder for
382
305
  the column being searched against.
383
306
  """
307
+ from plain.postgres.fields import (
308
+ EmailField,
309
+ GenericIPAddressField,
310
+ TextField,
311
+ )
312
+
384
313
  lookup = "%s"
385
314
 
386
- if lookup_type == "isnull" and internal_type in (
387
- "CharField",
388
- "EmailField",
389
- "TextField",
390
- ):
315
+ if lookup_type == "isnull" and isinstance(field, EmailField | TextField):
391
316
  return "%s::text"
392
317
 
393
318
  # Cast text lookups to text to allow things like filter(x__contains=4)
@@ -402,7 +327,7 @@ def lookup_cast(lookup_type: str, internal_type: str | None = None) -> str:
402
327
  "regex",
403
328
  "iregex",
404
329
  ):
405
- if internal_type == "GenericIPAddressField":
330
+ if isinstance(field, GenericIPAddressField):
406
331
  lookup = "HOST(%s)"
407
332
  else:
408
333
  lookup = "%s::text"
@@ -448,16 +373,6 @@ def prep_for_like_query(x: str) -> str:
448
373
  return str(x).replace("\\", "\\\\").replace("%", r"\%").replace("_", r"\_")
449
374
 
450
375
 
451
- def adapt_integerfield_value(
452
- value: int | Any | None, internal_type: str
453
- ) -> int | Any | None:
454
- from plain.postgres.expressions import ResolvableExpression
455
-
456
- if value is None or isinstance(value, ResolvableExpression):
457
- return value
458
- return INTEGERFIELD_TYPE_MAP[internal_type](value)
459
-
460
-
461
376
  def adapt_ipaddressfield_value(
462
377
  value: str | None,
463
378
  ) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
@@ -531,14 +446,17 @@ def combine_expression(connector: str, sub_expressions: list[str]) -> str:
531
446
 
532
447
 
533
448
  def subtract_temporals(
534
- internal_type: str,
449
+ field: Field,
535
450
  lhs: tuple[str, list[Any] | tuple[Any, ...]],
536
451
  rhs: tuple[str, list[Any] | tuple[Any, ...]],
537
452
  ) -> tuple[str, tuple[Any, ...]]:
453
+ from plain.postgres.fields import DateField, DateTimeField
454
+
538
455
  lhs_sql, lhs_params = lhs
539
456
  rhs_sql, rhs_params = rhs
540
457
  params = (*lhs_params, *rhs_params)
541
- if internal_type == "DateField":
458
+ # DateField (but not DateTimeField) needs interval conversion
459
+ if isinstance(field, DateField) and not isinstance(field, DateTimeField):
542
460
  return f"(interval '1 day' * ({lhs_sql} - {rhs_sql}))", params
543
461
  # Use native temporal subtraction
544
462
  return f"({lhs_sql} - {rhs_sql})", params