plain.postgres 0.89.1__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 (130) hide show
  1. plain_postgres-0.89.1/plain/postgres/README.md → plain_postgres-0.90.0/PKG-INFO +52 -21
  2. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/CHANGELOG.md +33 -0
  3. plain_postgres-0.89.1/PKG-INFO → plain_postgres-0.90.0/plain/postgres/README.md +40 -33
  4. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/__init__.py +0 -2
  5. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +3 -3
  6. plain_postgres-0.90.0/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +48 -0
  7. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/backups/cli.py +4 -2
  8. plain_postgres-0.90.0/plain/postgres/cli/converge.py +80 -0
  9. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/cli/core.py +7 -5
  10. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/cli/diagnose.py +3 -3
  11. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/cli/migrations.py +2 -1
  12. plain_postgres-0.90.0/plain/postgres/cli/schema.py +156 -0
  13. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/connection.py +2 -6
  14. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/dialect.py +14 -96
  15. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/expressions.py +17 -19
  16. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/fields/__init__.py +58 -155
  17. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/fields/encrypted.py +2 -8
  18. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/fields/json.py +1 -3
  19. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/fields/reverse_related.py +0 -3
  20. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/fields/timezones.py +5 -2
  21. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/forms.py +4 -13
  22. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/functions/text.py +5 -5
  23. plain_postgres-0.90.0/plain/postgres/introspection/__init__.py +33 -0
  24. plain_postgres-0.89.1/plain/postgres/diagnose/checks.py → plain_postgres-0.90.0/plain/postgres/introspection/health.py +200 -3
  25. plain_postgres-0.90.0/plain/postgres/introspection/schema.py +282 -0
  26. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/lookups.py +2 -5
  27. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/recorder.py +2 -2
  28. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/preflight.py +4 -8
  29. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/schema.py +29 -29
  30. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/types.py +0 -2
  31. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/types.pyi +1 -24
  32. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/pyproject.toml +2 -2
  33. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/app/examples/migrations/0001_initial.py +2 -2
  34. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +1 -1
  35. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +4 -4
  36. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +1 -1
  37. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/app/examples/migrations/0006_secretstore.py +1 -1
  38. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/app/examples/models.py +9 -9
  39. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/test_manager_assignment.py +3 -3
  40. plain_postgres-0.90.0/tests/test_schema_normalize_type.py +94 -0
  41. plain_postgres-0.89.1/plain/postgres/agents/.claude/skills/plain-postgres-diagnose/SKILL.md +0 -76
  42. plain_postgres-0.89.1/plain/postgres/cli/schema.py +0 -248
  43. plain_postgres-0.89.1/plain/postgres/diagnose/__init__.py +0 -42
  44. plain_postgres-0.89.1/plain/postgres/diagnose/context.py +0 -115
  45. plain_postgres-0.89.1/plain/postgres/diagnose/tables.py +0 -36
  46. plain_postgres-0.89.1/plain/postgres/diagnose/types.py +0 -26
  47. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/.gitignore +0 -0
  48. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/CLAUDE.md +0 -0
  49. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/LICENSE +0 -0
  50. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/README.md +0 -0
  51. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/aggregates.py +0 -0
  52. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/backups/__init__.py +0 -0
  53. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/backups/clients.py +0 -0
  54. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/backups/core.py +0 -0
  55. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/base.py +0 -0
  56. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/cli/__init__.py +0 -0
  57. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/config.py +0 -0
  58. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/connections.py +0 -0
  59. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/constants.py +0 -0
  60. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/constraints.py +0 -0
  61. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/database_url.py +0 -0
  62. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/db.py +0 -0
  63. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/default_settings.py +0 -0
  64. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/deletion.py +0 -0
  65. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/entrypoints.py +0 -0
  66. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/enums.py +0 -0
  67. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/exceptions.py +0 -0
  68. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/fields/mixins.py +0 -0
  69. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/fields/related.py +0 -0
  70. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/fields/related_descriptors.py +0 -0
  71. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/fields/related_lookups.py +0 -0
  72. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/fields/related_managers.py +0 -0
  73. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
  74. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/functions/__init__.py +0 -0
  75. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/functions/comparison.py +0 -0
  76. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/functions/datetime.py +0 -0
  77. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/functions/math.py +0 -0
  78. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/functions/mixins.py +0 -0
  79. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/functions/window.py +0 -0
  80. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/indexes.py +0 -0
  81. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/meta.py +0 -0
  82. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/__init__.py +0 -0
  83. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/autodetector.py +0 -0
  84. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/exceptions.py +0 -0
  85. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/executor.py +0 -0
  86. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/graph.py +0 -0
  87. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/loader.py +0 -0
  88. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/migration.py +0 -0
  89. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/operations/__init__.py +0 -0
  90. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/operations/base.py +0 -0
  91. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/operations/fields.py +0 -0
  92. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/operations/models.py +0 -0
  93. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/operations/special.py +0 -0
  94. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/optimizer.py +0 -0
  95. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/questioner.py +0 -0
  96. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/serializer.py +0 -0
  97. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/state.py +0 -0
  98. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/utils.py +0 -0
  99. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/writer.py +0 -0
  100. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/options.py +0 -0
  101. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/otel.py +0 -0
  102. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/query.py +0 -0
  103. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/query_utils.py +0 -0
  104. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/registry.py +0 -0
  105. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/sql/__init__.py +0 -0
  106. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/sql/compiler.py +0 -0
  107. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/sql/constants.py +0 -0
  108. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/sql/datastructures.py +0 -0
  109. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/sql/query.py +0 -0
  110. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/sql/where.py +0 -0
  111. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/test/__init__.py +0 -0
  112. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/test/pytest.py +0 -0
  113. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/test/utils.py +0 -0
  114. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/transaction.py +0 -0
  115. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/utils.py +0 -0
  116. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
  117. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/app/examples/migrations/__init__.py +0 -0
  118. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/app/settings.py +0 -0
  119. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/app/urls.py +0 -0
  120. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/test_connection_isolation.py +0 -0
  121. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/test_connection_lifecycle.py +0 -0
  122. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/test_database_url.py +0 -0
  123. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/test_delete_behaviors.py +0 -0
  124. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/test_encrypted_fields.py +0 -0
  125. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/test_exceptions.py +0 -0
  126. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/test_iterator.py +0 -0
  127. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/test_models.py +0 -0
  128. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/test_read_only_transactions.py +0 -0
  129. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/test_related_descriptors.py +0 -0
  130. {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/test_related_manager_api.py +0 -0
@@ -1,3 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: plain.postgres
3
+ Version: 0.90.0
4
+ Summary: Model your data and store it in a database.
5
+ Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
+ License-Expression: BSD-3-Clause
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.13
9
+ Requires-Dist: plain<1.0.0,>=0.129.0
10
+ Requires-Dist: sqlparse>=0.3.1
11
+ Description-Content-Type: text/markdown
12
+
1
13
  # plain.postgres
2
14
 
3
15
  **Model your data and store it in a database.**
@@ -142,8 +154,8 @@ class PublishedQuerySet(postgres.QuerySet["Article"]):
142
154
 
143
155
  @postgres.register_model
144
156
  class Article(postgres.Model):
145
- title: str = types.CharField(max_length=200)
146
- status: str = types.CharField(max_length=20)
157
+ title: str = types.TextField(max_length=200)
158
+ status: str = types.TextField(max_length=20)
147
159
 
148
160
  query = PublishedQuerySet()
149
161
 
@@ -496,7 +508,31 @@ Use this when migrations have already been committed or deployed to other enviro
496
508
  | ----------------------------------------- | ------------------------------------------------------- |
497
509
  | Migrations are local only (not committed) | Delete-and-recreate |
498
510
  | Migrations are committed but not deployed | Delete-and-recreate (if all developers reset) or squash |
499
- | Migrations are deployed to production | Squash |
511
+ | Migrations are deployed to production | Squash or full reset |
512
+
513
+ ### Resetting migrations
514
+
515
+ Over time a package can accumulate dozens of migrations. Once **every environment** (dev, staging, production) has applied all of them, you can replace the entire history with a single fresh `0001_initial`.
516
+
517
+ **Prerequisites:**
518
+
519
+ - Every environment (dev, staging, production) has applied all existing migrations. If any environment is behind, the reset will break it.
520
+ - The first migration is named `0001_initial` (the default). If it has a different name, this workflow won't work cleanly.
521
+
522
+ **Steps:**
523
+
524
+ 1. Run `plain migrations list` locally and verify everything is applied.
525
+ 2. Delete every file in the package's `migrations/` directory except `__init__.py`.
526
+ 3. Run `plain makemigrations` to generate a fresh `0001_initial`.
527
+ 4. Run `plain migrations prune --yes` to remove stale DB records. The existing `0001_initial` record matches the new file, so the database is immediately up to date.
528
+ 5. Verify with `plain postgres schema` (zero issues means the reset is clean) and `plain makemigrations --check` (no pending changes).
529
+ 6. Commit and deploy. On every other environment, run `plain migrations prune --yes`. No actual SQL runs — it only cleans up migration history records. If `migrations prune` is already in your deploy steps, no changes are needed.
530
+
531
+ **Things to keep in mind:**
532
+
533
+ - If resetting multiple packages, process depended-on packages first — the new `0001_initial` may have cross-package FK dependencies.
534
+ - Data migrations (`RunPython`) in the deleted history are gone, which is fine since they've already run everywhere.
535
+ - If CI runs `makemigrations --check` or `migrate --check`, the reset PR must be merged and deployed before those checks pass in other branches.
500
536
 
501
537
  ### Other migration commands
502
538
 
@@ -516,7 +552,7 @@ from plain.postgres import types
516
552
 
517
553
  class Product(postgres.Model):
518
554
  # Text fields
519
- name: str = types.CharField(max_length=200)
555
+ name: str = types.TextField(max_length=200)
520
556
  description: str = types.TextField()
521
557
 
522
558
  # Numeric fields
@@ -533,8 +569,7 @@ class Product(postgres.Model):
533
569
 
534
570
  **Text fields:**
535
571
 
536
- - [`CharField`](./fields/__init__.py#CharField) - String with max length
537
- - [`TextField`](./fields/__init__.py#TextField) - Unlimited text
572
+ - [`TextField`](./fields/__init__.py#TextField) - Text (with optional max length)
538
573
  - [`EmailField`](./fields/__init__.py#EmailField) - Email address (validated)
539
574
  - [`URLField`](./fields/__init__.py#URLField) - URL (validated)
540
575
 
@@ -623,7 +658,7 @@ from plain.postgres import types
623
658
 
624
659
  @postgres.register_model
625
660
  class Integration(postgres.Model):
626
- name: str = types.CharField(max_length=100)
661
+ name: str = types.TextField(max_length=100)
627
662
  api_key: str = types.EncryptedTextField(max_length=200)
628
663
  credentials: dict = types.EncryptedJSONField(required=False, allow_null=True)
629
664
  ```
@@ -658,7 +693,7 @@ from plain.postgres import types
658
693
 
659
694
  @postgres.register_model
660
695
  class Book(postgres.Model):
661
- title: str = types.CharField(max_length=200)
696
+ title: str = types.TextField(max_length=200)
662
697
  author: Author = types.ForeignKeyField("Author", on_delete=postgres.CASCADE)
663
698
  tags = types.ManyToManyField("Tag")
664
699
  ```
@@ -673,13 +708,13 @@ from plain.postgres import types
673
708
 
674
709
  @postgres.register_model
675
710
  class Author(postgres.Model):
676
- name: str = types.CharField(max_length=200)
711
+ name: str = types.TextField(max_length=200)
677
712
  # Explicit reverse accessor for all books by this author
678
713
  books = types.ReverseForeignKey(to="Book", field="author")
679
714
 
680
715
  @postgres.register_model
681
716
  class Book(postgres.Model):
682
- title: str = types.CharField(max_length=200)
717
+ title: str = types.TextField(max_length=200)
683
718
  author: Author = types.ForeignKeyField(Author, on_delete=postgres.CASCADE)
684
719
 
685
720
  # Usage
@@ -696,13 +731,13 @@ For many-to-many relationships:
696
731
  ```python
697
732
  @postgres.register_model
698
733
  class Feature(postgres.Model):
699
- name: str = types.CharField(max_length=100)
734
+ name: str = types.TextField(max_length=100)
700
735
  # Explicit reverse accessor for all cars with this feature
701
736
  cars = types.ReverseManyToMany(to="Car", field="features")
702
737
 
703
738
  @postgres.register_model
704
739
  class Car(postgres.Model):
705
- model: str = types.CharField(max_length=100)
740
+ model: str = types.TextField(max_length=100)
706
741
  features = types.ManyToManyField(Feature)
707
742
 
708
743
  # Usage
@@ -769,7 +804,7 @@ You can optimize queries and ensure data integrity with indexes and constraints:
769
804
  ```python
770
805
  class User(postgres.Model):
771
806
  email: str = types.EmailField()
772
- username: str = types.CharField(max_length=150)
807
+ username: str = types.TextField(max_length=150)
773
808
  age: int = types.IntegerField()
774
809
 
775
810
  model_options = postgres.Options(
@@ -793,12 +828,12 @@ Add indexes for columns that appear in `.filter()`, `.order_by()`, or `.exclude(
793
828
  ```python
794
829
  # Bad — full table scan on every filtered query
795
830
  class Order(postgres.Model):
796
- status: str = types.CharField(max_length=20)
831
+ status: str = types.TextField(max_length=20)
797
832
  created_at: datetime = types.DateTimeField()
798
833
 
799
834
  # Good — indexed for common queries
800
835
  class Order(postgres.Model):
801
- status: str = types.CharField(max_length=20)
836
+ status: str = types.TextField(max_length=20)
802
837
  created_at: datetime = types.DateTimeField()
803
838
 
804
839
  model_options = postgres.Options(
@@ -840,10 +875,10 @@ Use `default=""` instead of `allow_null=True` to avoid two representations of "e
840
875
 
841
876
  ```python
842
877
  # Bad — NULL and "" both mean "empty"
843
- nickname: str = types.CharField(max_length=50, allow_null=True)
878
+ nickname: str = types.TextField(max_length=50, allow_null=True)
844
879
 
845
880
  # Good — single empty representation
846
- nickname: str = types.CharField(max_length=50, default="")
881
+ nickname: str = types.TextField(max_length=50, default="")
847
882
  ```
848
883
 
849
884
  ## Forms
@@ -1005,10 +1040,6 @@ See [`default_settings.py`](./default_settings.py) for more details.
1005
1040
 
1006
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.
1007
1042
 
1008
- #### What's the difference between `CharField` and `TextField`?
1009
-
1010
- `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.
1011
-
1012
1043
  #### How do I create a unique constraint on multiple fields?
1013
1044
 
1014
1045
  Use `UniqueConstraint` in your model's `model_options`:
@@ -1,5 +1,38 @@
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
+
20
+ ## [0.89.2](https://github.com/dropseed/plain/releases/plain-postgres@0.89.2) (2026-03-27)
21
+
22
+ ### What's changed
23
+
24
+ - Fixed `schema` command miscategorizing expression-based unique constraints as missing columns ([93ab244416f8](https://github.com/dropseed/plain/commit/93ab244416f8))
25
+ - Used canonical Postgres type names in `DATA_TYPES` mapping, removing the `_normalize_type` helper ([f581fe6009bd](https://github.com/dropseed/plain/commit/f581fe6009bd))
26
+ - Moved `diagnose/` module to `introspection/`, consolidated into 2 files, added schema introspection functions used by `schema` and `drop-unknown-tables` commands ([86f7f5b85a87](https://github.com/dropseed/plain/commit/86f7f5b85a87))
27
+ - `diagnose --json` now exits 0 — the JSON data is the signal, not the exit code ([86f7f5b85a87](https://github.com/dropseed/plain/commit/86f7f5b85a87))
28
+ - Added migration reset documentation for replacing migration history with a fresh `0001_initial` ([2fa6203379e9](https://github.com/dropseed/plain/commit/2fa6203379e9))
29
+ - Updated form field references from `CharField` to `TextField` in model forms ([4e29f5d6cade](https://github.com/dropseed/plain/commit/4e29f5d6cade))
30
+ - Changed CLI confirmation flags to `--yes`/`-y` across all commands ([0af36e101f03](https://github.com/dropseed/plain/commit/0af36e101f03))
31
+
32
+ ### Upgrade instructions
33
+
34
+ - Requires `plain>=0.129.0`. If you use `plain postgres diagnose --json` exit codes in CI, note that it now always exits 0 — check the JSON output for issues instead.
35
+
3
36
  ## [0.89.1](https://github.com/dropseed/plain/releases/plain-postgres@0.89.1) (2026-03-26)
4
37
 
5
38
  ### What's changed
@@ -1,15 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: plain.postgres
3
- Version: 0.89.1
4
- Summary: Model your data and store it in a database.
5
- Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
- License-Expression: BSD-3-Clause
7
- License-File: LICENSE
8
- Requires-Python: >=3.13
9
- Requires-Dist: plain<1.0.0,>=0.126.0
10
- Requires-Dist: sqlparse>=0.3.1
11
- Description-Content-Type: text/markdown
12
-
13
1
  # plain.postgres
14
2
 
15
3
  **Model your data and store it in a database.**
@@ -154,8 +142,8 @@ class PublishedQuerySet(postgres.QuerySet["Article"]):
154
142
 
155
143
  @postgres.register_model
156
144
  class Article(postgres.Model):
157
- title: str = types.CharField(max_length=200)
158
- status: str = types.CharField(max_length=20)
145
+ title: str = types.TextField(max_length=200)
146
+ status: str = types.TextField(max_length=20)
159
147
 
160
148
  query = PublishedQuerySet()
161
149
 
@@ -508,7 +496,31 @@ Use this when migrations have already been committed or deployed to other enviro
508
496
  | ----------------------------------------- | ------------------------------------------------------- |
509
497
  | Migrations are local only (not committed) | Delete-and-recreate |
510
498
  | Migrations are committed but not deployed | Delete-and-recreate (if all developers reset) or squash |
511
- | Migrations are deployed to production | Squash |
499
+ | Migrations are deployed to production | Squash or full reset |
500
+
501
+ ### Resetting migrations
502
+
503
+ Over time a package can accumulate dozens of migrations. Once **every environment** (dev, staging, production) has applied all of them, you can replace the entire history with a single fresh `0001_initial`.
504
+
505
+ **Prerequisites:**
506
+
507
+ - Every environment (dev, staging, production) has applied all existing migrations. If any environment is behind, the reset will break it.
508
+ - The first migration is named `0001_initial` (the default). If it has a different name, this workflow won't work cleanly.
509
+
510
+ **Steps:**
511
+
512
+ 1. Run `plain migrations list` locally and verify everything is applied.
513
+ 2. Delete every file in the package's `migrations/` directory except `__init__.py`.
514
+ 3. Run `plain makemigrations` to generate a fresh `0001_initial`.
515
+ 4. Run `plain migrations prune --yes` to remove stale DB records. The existing `0001_initial` record matches the new file, so the database is immediately up to date.
516
+ 5. Verify with `plain postgres schema` (zero issues means the reset is clean) and `plain makemigrations --check` (no pending changes).
517
+ 6. Commit and deploy. On every other environment, run `plain migrations prune --yes`. No actual SQL runs — it only cleans up migration history records. If `migrations prune` is already in your deploy steps, no changes are needed.
518
+
519
+ **Things to keep in mind:**
520
+
521
+ - If resetting multiple packages, process depended-on packages first — the new `0001_initial` may have cross-package FK dependencies.
522
+ - Data migrations (`RunPython`) in the deleted history are gone, which is fine since they've already run everywhere.
523
+ - If CI runs `makemigrations --check` or `migrate --check`, the reset PR must be merged and deployed before those checks pass in other branches.
512
524
 
513
525
  ### Other migration commands
514
526
 
@@ -528,7 +540,7 @@ from plain.postgres import types
528
540
 
529
541
  class Product(postgres.Model):
530
542
  # Text fields
531
- name: str = types.CharField(max_length=200)
543
+ name: str = types.TextField(max_length=200)
532
544
  description: str = types.TextField()
533
545
 
534
546
  # Numeric fields
@@ -545,8 +557,7 @@ class Product(postgres.Model):
545
557
 
546
558
  **Text fields:**
547
559
 
548
- - [`CharField`](./fields/__init__.py#CharField) - String with max length
549
- - [`TextField`](./fields/__init__.py#TextField) - Unlimited text
560
+ - [`TextField`](./fields/__init__.py#TextField) - Text (with optional max length)
550
561
  - [`EmailField`](./fields/__init__.py#EmailField) - Email address (validated)
551
562
  - [`URLField`](./fields/__init__.py#URLField) - URL (validated)
552
563
 
@@ -635,7 +646,7 @@ from plain.postgres import types
635
646
 
636
647
  @postgres.register_model
637
648
  class Integration(postgres.Model):
638
- name: str = types.CharField(max_length=100)
649
+ name: str = types.TextField(max_length=100)
639
650
  api_key: str = types.EncryptedTextField(max_length=200)
640
651
  credentials: dict = types.EncryptedJSONField(required=False, allow_null=True)
641
652
  ```
@@ -670,7 +681,7 @@ from plain.postgres import types
670
681
 
671
682
  @postgres.register_model
672
683
  class Book(postgres.Model):
673
- title: str = types.CharField(max_length=200)
684
+ title: str = types.TextField(max_length=200)
674
685
  author: Author = types.ForeignKeyField("Author", on_delete=postgres.CASCADE)
675
686
  tags = types.ManyToManyField("Tag")
676
687
  ```
@@ -685,13 +696,13 @@ from plain.postgres import types
685
696
 
686
697
  @postgres.register_model
687
698
  class Author(postgres.Model):
688
- name: str = types.CharField(max_length=200)
699
+ name: str = types.TextField(max_length=200)
689
700
  # Explicit reverse accessor for all books by this author
690
701
  books = types.ReverseForeignKey(to="Book", field="author")
691
702
 
692
703
  @postgres.register_model
693
704
  class Book(postgres.Model):
694
- title: str = types.CharField(max_length=200)
705
+ title: str = types.TextField(max_length=200)
695
706
  author: Author = types.ForeignKeyField(Author, on_delete=postgres.CASCADE)
696
707
 
697
708
  # Usage
@@ -708,13 +719,13 @@ For many-to-many relationships:
708
719
  ```python
709
720
  @postgres.register_model
710
721
  class Feature(postgres.Model):
711
- name: str = types.CharField(max_length=100)
722
+ name: str = types.TextField(max_length=100)
712
723
  # Explicit reverse accessor for all cars with this feature
713
724
  cars = types.ReverseManyToMany(to="Car", field="features")
714
725
 
715
726
  @postgres.register_model
716
727
  class Car(postgres.Model):
717
- model: str = types.CharField(max_length=100)
728
+ model: str = types.TextField(max_length=100)
718
729
  features = types.ManyToManyField(Feature)
719
730
 
720
731
  # Usage
@@ -781,7 +792,7 @@ You can optimize queries and ensure data integrity with indexes and constraints:
781
792
  ```python
782
793
  class User(postgres.Model):
783
794
  email: str = types.EmailField()
784
- username: str = types.CharField(max_length=150)
795
+ username: str = types.TextField(max_length=150)
785
796
  age: int = types.IntegerField()
786
797
 
787
798
  model_options = postgres.Options(
@@ -805,12 +816,12 @@ Add indexes for columns that appear in `.filter()`, `.order_by()`, or `.exclude(
805
816
  ```python
806
817
  # Bad — full table scan on every filtered query
807
818
  class Order(postgres.Model):
808
- status: str = types.CharField(max_length=20)
819
+ status: str = types.TextField(max_length=20)
809
820
  created_at: datetime = types.DateTimeField()
810
821
 
811
822
  # Good — indexed for common queries
812
823
  class Order(postgres.Model):
813
- status: str = types.CharField(max_length=20)
824
+ status: str = types.TextField(max_length=20)
814
825
  created_at: datetime = types.DateTimeField()
815
826
 
816
827
  model_options = postgres.Options(
@@ -852,10 +863,10 @@ Use `default=""` instead of `allow_null=True` to avoid two representations of "e
852
863
 
853
864
  ```python
854
865
  # Bad — NULL and "" both mean "empty"
855
- nickname: str = types.CharField(max_length=50, allow_null=True)
866
+ nickname: str = types.TextField(max_length=50, allow_null=True)
856
867
 
857
868
  # Good — single empty representation
858
- nickname: str = types.CharField(max_length=50, default="")
869
+ nickname: str = types.TextField(max_length=50, default="")
859
870
  ```
860
871
 
861
872
  ## Forms
@@ -1017,10 +1028,6 @@ See [`default_settings.py`](./default_settings.py) for more details.
1017
1028
 
1018
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.
1019
1030
 
1020
- #### What's the difference between `CharField` and `TextField`?
1021
-
1022
- `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.
1023
-
1024
1031
  #### How do I create a unique constraint on multiple fields?
1025
1032
 
1026
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
 
@@ -69,9 +69,9 @@ Run `uv run plain docs postgres --section querying` for full patterns with code
69
69
 
70
70
  Run `uv run plain docs postgres --section constraints` for full patterns with code examples.
71
71
 
72
- ## Diagnostics
72
+ ## Database Doctor
73
73
 
74
- Use the `/plain-postgres-diagnose` skill to run database health checks (unused indexes, missing FK indexes, sequence exhaustion, etc.).
74
+ Use the `/plain-postgres-doctor` skill to check overall database health migration sync, schema correctness, and operational health.
75
75
 
76
76
  Run `uv run plain docs postgres --section diagnostics` for check details, thresholds, and production usage.
77
77
 
@@ -0,0 +1,48 @@
1
+ ---
2
+ name: plain-postgres-doctor
3
+ description: Check overall database health — schema correctness and operational health. Use when asked to check the database, validate schema, optimize indexes, or diagnose Postgres problems.
4
+ ---
5
+
6
+ # Database Doctor
7
+
8
+ Check database health by running schema and operational checks, then fix any issues found.
9
+
10
+ **All checks are read-only.** Fixes are local code changes. Never run database mutations (`migrate`, direct SQL) without explicit user approval.
11
+
12
+ ## 1. Dev only or production too?
13
+
14
+ Always start by checking the dev database — that's where you are and where fixes get verified. Ask the user if they also want to check production. Production is where schema drift and operational health issues matter most, but dev databases drift too (manual SQL, reverted migrations, branch switches).
15
+
16
+ If checking production, figure out how the user runs commands there (check for Procfiles, Dockerfiles, deploy scripts, etc.) or ask them. Both `schema` and `diagnose` need to run against the target database.
17
+
18
+ ## 2. Run checks
19
+
20
+ ### Schema correctness
21
+
22
+ ```
23
+ uv run plain postgres schema --json
24
+ ```
25
+
26
+ Checks whether the actual database matches what the models expect — drift from failed deploys, manual DDL, partial migrations, or branch switches. Also reports unknown tables (tables in the DB with no corresponding model) which are often left over from uninstalled packages.
27
+
28
+ ### Operational health
29
+
30
+ ```
31
+ uv run plain postgres diagnose --json
32
+ ```
33
+
34
+ Finds unused/duplicate/missing indexes, sequence exhaustion, cache hit ratios, vacuum health, and slow queries. Stats-based checks (unused indexes, cache hit ratios) are most meaningful against production with real traffic. Structural checks (duplicate indexes, missing FK indexes) are valid in any environment.
35
+
36
+ The JSON output includes `suggestion` fields for each finding. If findings are on unmanaged tables, check whether those tables should exist at all — `schema` will have already flagged them as unknown.
37
+
38
+ ## 3. Fix issues
39
+
40
+ Make code and migration changes in the local codebase. For app-owned items, this is typically model changes + `uv run plain makemigrations`. For unknown tables, present `uv run plain postgres drop-unknown-tables` to the user — it shows what will be dropped and asks for confirmation. Use `--yes` to skip the prompt if the user wants the agent to run it directly. For other unmanaged items, the suggestions include exact DDL — present these to the user for review, do not run SQL directly.
41
+
42
+ ## 4. Verify
43
+
44
+ For **structural diagnose findings** (duplicate indexes, missing FK indexes): confirm the issue also appears in dev before fixing — you can't verify a fix if you never saw the problem. Run checks before and after the fix, then deploy and re-verify in production.
45
+
46
+ For **schema drift**: the drift is environment-specific (prod may have manual DDL that dev doesn't). Verify by confirming your fix makes `schema` pass cleanly in dev, then deploy and re-verify in production.
47
+
48
+ Stats-based findings (unused indexes, cache hit ratios) can only be verified in production after deploy.
@@ -138,11 +138,13 @@ def delete_backup(backup_name: str) -> None:
138
138
 
139
139
 
140
140
  @cli.command("clear")
141
- @click.confirmation_option(prompt="Are you sure you want to delete all backups?")
142
- def clear_backups() -> None:
141
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt.")
142
+ def clear_backups(yes: bool) -> None:
143
143
  """Clear all database backups"""
144
144
  backups_handler = DatabaseBackups()
145
145
  backups = backups_handler.find_backups()
146
+ if not yes:
147
+ click.confirm("Are you sure you want to delete all backups?", abort=True)
146
148
  for backup in backups:
147
149
  backup.delete()
148
150
  click.secho("All backups deleted", fg="green")
@@ -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,7 +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 ..migrations.recorder import MIGRATION_TABLE_NAME
16
+ from .converge import converge
17
17
  from .diagnose import diagnose
18
18
  from .schema import schema
19
19
 
@@ -25,6 +25,7 @@ def cli() -> None:
25
25
 
26
26
 
27
27
  cli.add_command(backups_cli)
28
+ cli.add_command(converge)
28
29
  cli.add_command(diagnose)
29
30
  cli.add_command(schema)
30
31
 
@@ -62,15 +63,16 @@ def shell(parameters: tuple[str, ...]) -> None:
62
63
  @cli.command("drop-unknown-tables")
63
64
  @click.option(
64
65
  "--yes",
66
+ "-y",
65
67
  is_flag=True,
66
- help="Skip confirmation prompt (for non-interactive use).",
68
+ help="Skip confirmation prompt.",
67
69
  )
68
70
  def drop_unknown_tables(yes: bool) -> None:
69
71
  """Drop all tables not associated with a Plain model"""
72
+ from ..introspection import get_unknown_tables
73
+
70
74
  conn = get_connection()
71
- db_tables = set(conn.table_names())
72
- model_tables = set(conn.plain_table_names())
73
- unknown_tables = sorted(db_tables - model_tables - {MIGRATION_TABLE_NAME})
75
+ unknown_tables = get_unknown_tables(conn)
74
76
 
75
77
  if not unknown_tables:
76
78
  click.echo("No unknown tables found.")
@@ -7,7 +7,7 @@ from typing import Any
7
7
  import click
8
8
 
9
9
  from ..db import get_connection
10
- from ..diagnose import CheckItem, CheckResult, build_table_owners, run_all_checks
10
+ from ..introspection import CheckItem, CheckResult, build_table_owners, run_all_checks
11
11
 
12
12
  STATUS_SYMBOLS = {
13
13
  "ok": ("✓", "green"),
@@ -199,6 +199,6 @@ def diagnose(output_json: bool, show_all: bool) -> None:
199
199
  else:
200
200
  format_human(results, context, show_all=show_all)
201
201
 
202
- # Exit 1 if any critical
203
- if any(r["status"] == "critical" for r in results):
202
+ # Exit 1 if any critical (JSON mode always exits 0 — the data is the signal)
203
+ if not output_json and any(r["status"] == "critical" for r in results):
204
204
  sys.exit(1)
@@ -777,8 +777,9 @@ def list_migrations(
777
777
  @cli.command("prune")
778
778
  @click.option(
779
779
  "--yes",
780
+ "-y",
780
781
  is_flag=True,
781
- help="Skip confirmation prompt (for non-interactive use).",
782
+ help="Skip confirmation prompt.",
782
783
  )
783
784
  def prune(yes: bool) -> None:
784
785
  """Remove stale migration records from the database"""