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.
- plain_postgres-0.89.1/plain/postgres/README.md → plain_postgres-0.90.0/PKG-INFO +52 -21
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/CHANGELOG.md +33 -0
- plain_postgres-0.89.1/PKG-INFO → plain_postgres-0.90.0/plain/postgres/README.md +40 -33
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/__init__.py +0 -2
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +3 -3
- plain_postgres-0.90.0/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +48 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/backups/cli.py +4 -2
- plain_postgres-0.90.0/plain/postgres/cli/converge.py +80 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/cli/core.py +7 -5
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/cli/diagnose.py +3 -3
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/cli/migrations.py +2 -1
- plain_postgres-0.90.0/plain/postgres/cli/schema.py +156 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/connection.py +2 -6
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/dialect.py +14 -96
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/expressions.py +17 -19
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/fields/__init__.py +58 -155
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/fields/encrypted.py +2 -8
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/fields/json.py +1 -3
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/fields/reverse_related.py +0 -3
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/fields/timezones.py +5 -2
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/forms.py +4 -13
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/functions/text.py +5 -5
- plain_postgres-0.90.0/plain/postgres/introspection/__init__.py +33 -0
- plain_postgres-0.89.1/plain/postgres/diagnose/checks.py → plain_postgres-0.90.0/plain/postgres/introspection/health.py +200 -3
- plain_postgres-0.90.0/plain/postgres/introspection/schema.py +282 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/lookups.py +2 -5
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/recorder.py +2 -2
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/preflight.py +4 -8
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/schema.py +29 -29
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/types.py +0 -2
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/types.pyi +1 -24
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/pyproject.toml +2 -2
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/app/examples/migrations/0001_initial.py +2 -2
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +1 -1
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +4 -4
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +1 -1
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/app/examples/migrations/0006_secretstore.py +1 -1
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/app/examples/models.py +9 -9
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/test_manager_assignment.py +3 -3
- plain_postgres-0.90.0/tests/test_schema_normalize_type.py +94 -0
- plain_postgres-0.89.1/plain/postgres/agents/.claude/skills/plain-postgres-diagnose/SKILL.md +0 -76
- plain_postgres-0.89.1/plain/postgres/cli/schema.py +0 -248
- plain_postgres-0.89.1/plain/postgres/diagnose/__init__.py +0 -42
- plain_postgres-0.89.1/plain/postgres/diagnose/context.py +0 -115
- plain_postgres-0.89.1/plain/postgres/diagnose/tables.py +0 -36
- plain_postgres-0.89.1/plain/postgres/diagnose/types.py +0 -26
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/.gitignore +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/CLAUDE.md +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/LICENSE +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/README.md +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/aggregates.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/backups/__init__.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/backups/clients.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/backups/core.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/base.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/cli/__init__.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/config.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/connections.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/constants.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/constraints.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/database_url.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/db.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/default_settings.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/deletion.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/entrypoints.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/enums.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/exceptions.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/fields/mixins.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/fields/related.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/fields/related_descriptors.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/fields/related_lookups.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/fields/related_managers.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/functions/__init__.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/functions/comparison.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/functions/datetime.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/functions/math.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/functions/mixins.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/functions/window.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/indexes.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/meta.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/__init__.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/autodetector.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/exceptions.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/executor.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/graph.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/loader.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/migration.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/operations/__init__.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/operations/base.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/operations/fields.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/operations/models.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/operations/special.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/optimizer.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/questioner.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/serializer.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/state.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/utils.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/migrations/writer.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/options.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/otel.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/query.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/query_utils.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/registry.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/sql/__init__.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/sql/compiler.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/sql/constants.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/sql/datastructures.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/sql/query.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/sql/where.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/test/__init__.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/test/pytest.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/test/utils.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/transaction.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/plain/postgres/utils.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/app/examples/migrations/__init__.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/app/settings.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/app/urls.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/test_connection_isolation.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/test_connection_lifecycle.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/test_database_url.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/test_delete_behaviors.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/test_encrypted_fields.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/test_exceptions.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/test_iterator.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/test_models.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/test_read_only_transactions.py +0 -0
- {plain_postgres-0.89.1 → plain_postgres-0.90.0}/tests/test_related_descriptors.py +0 -0
- {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.
|
|
146
|
-
status: str = types.
|
|
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.
|
|
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
|
-
- [`
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
878
|
+
nickname: str = types.TextField(max_length=50, allow_null=True)
|
|
844
879
|
|
|
845
880
|
# Good — single empty representation
|
|
846
|
-
nickname: str = types.
|
|
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.
|
|
158
|
-
status: str = types.
|
|
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.
|
|
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
|
-
- [`
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
866
|
+
nickname: str = types.TextField(max_length=50, allow_null=True)
|
|
856
867
|
|
|
857
868
|
# Good — single empty representation
|
|
858
|
-
nickname: str = types.
|
|
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.
|
|
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
|
-
##
|
|
72
|
+
## Database Doctor
|
|
73
73
|
|
|
74
|
-
Use the `/plain-postgres-
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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 ..
|
|
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
|
|
782
|
+
help="Skip confirmation prompt.",
|
|
782
783
|
)
|
|
783
784
|
def prune(yes: bool) -> None:
|
|
784
785
|
"""Remove stale migration records from the database"""
|