plain.postgres 0.95.0__tar.gz → 0.97.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.95.0 → plain_postgres-0.97.0}/PKG-INFO +143 -54
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/CHANGELOG.md +80 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/README.md +142 -53
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/__init__.py +5 -3
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/base.py +66 -33
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/cli/converge.py +2 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/cli/core.py +3 -0
- plain_postgres-0.97.0/plain/postgres/cli/decorators.py +24 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/cli/diagnose.py +2 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/cli/migrations.py +45 -14
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/cli/schema.py +3 -1
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/cli/sync.py +20 -10
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/connection.py +37 -384
- plain_postgres-0.97.0/plain/postgres/connections.py +126 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/convergence/__init__.py +8 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/convergence/analysis.py +153 -21
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/convergence/fixes.py +135 -23
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/convergence/planning.py +13 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/database_url.py +24 -2
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/db.py +2 -1
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/ddl.py +24 -0
- plain_postgres-0.97.0/plain/postgres/default_settings.py +38 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/dialect.py +45 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/enums.py +9 -15
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/expressions.py +4 -2
- plain_postgres-0.97.0/plain/postgres/fields/__init__.py +51 -0
- plain_postgres-0.97.0/plain/postgres/fields/base.py +867 -0
- plain_postgres-0.97.0/plain/postgres/fields/binary.py +65 -0
- plain_postgres-0.97.0/plain/postgres/fields/boolean.py +38 -0
- plain_postgres-0.97.0/plain/postgres/fields/duration.py +51 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/fields/encrypted.py +34 -43
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/fields/json.py +16 -48
- plain_postgres-0.97.0/plain/postgres/fields/network.py +101 -0
- plain_postgres-0.97.0/plain/postgres/fields/numeric.py +278 -0
- plain_postgres-0.97.0/plain/postgres/fields/primary_key.py +86 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/fields/related.py +76 -68
- plain_postgres-0.97.0/plain/postgres/fields/related_lookups.py +103 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/fields/reverse_related.py +3 -7
- plain_postgres-0.97.0/plain/postgres/fields/temporal.py +381 -0
- plain_postgres-0.97.0/plain/postgres/fields/text.py +131 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/fields/timezones.py +27 -15
- plain_postgres-0.97.0/plain/postgres/fields/uuid.py +78 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/forms.py +52 -31
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/functions/__init__.py +6 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/functions/datetime.py +13 -6
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/functions/mixins.py +8 -2
- plain_postgres-0.97.0/plain/postgres/functions/random.py +51 -0
- plain_postgres-0.97.0/plain/postgres/functions/uuid.py +9 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/introspection/__init__.py +2 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/introspection/schema.py +249 -14
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/meta.py +1 -1
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/migrations/autodetector.py +109 -45
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/migrations/exceptions.py +9 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/migrations/operations/fields.py +2 -23
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/migrations/operations/special.py +12 -2
- plain_postgres-0.97.0/plain/postgres/migrations/questioner.py +109 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/migrations/recorder.py +1 -2
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/migrations/serializer.py +2 -2
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/migrations/state.py +0 -14
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/query.py +12 -5
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/query_utils.py +7 -13
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/schema.py +122 -245
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/sql/compiler.py +31 -93
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/sql/query.py +15 -75
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/sql/where.py +0 -24
- plain_postgres-0.97.0/plain/postgres/test/database.py +150 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/test/pytest.py +24 -24
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/types.py +2 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/types.pyi +24 -163
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/utils.py +2 -21
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/pyproject.toml +1 -1
- plain_postgres-0.97.0/tests/app/examples/forms.py +48 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +4 -4
- plain_postgres-0.97.0/tests/app/examples/migrations/0011_defaultsexample.py +28 -0
- plain_postgres-0.97.0/tests/app/examples/migrations/0012_iterationexample.py +21 -0
- plain_postgres-0.97.0/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +36 -0
- plain_postgres-0.97.0/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +64 -0
- plain_postgres-0.97.0/tests/app/examples/migrations/0015_dbdefaultsexample.py +22 -0
- plain_postgres-0.97.0/tests/app/examples/migrations/0016_formsexample.py +41 -0
- plain_postgres-0.97.0/tests/app/examples/migrations/0017_random_string_token.py +18 -0
- plain_postgres-0.97.0/tests/app/examples/models/__init__.py +18 -0
- plain_postgres-0.97.0/tests/app/examples/models/constraints.py +28 -0
- plain_postgres-0.97.0/tests/app/examples/models/defaults.py +50 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/tests/app/examples/models/delete.py +1 -7
- plain_postgres-0.97.0/tests/app/examples/models/encrypted.py +16 -0
- plain_postgres-0.97.0/tests/app/examples/models/forms.py +35 -0
- plain_postgres-0.97.0/tests/app/examples/models/indexes.py +19 -0
- plain_postgres-0.97.0/tests/app/examples/models/iteration.py +20 -0
- plain_postgres-0.97.0/tests/app/examples/models/mixins.py +26 -0
- plain_postgres-0.97.0/tests/app/examples/models/nullability.py +17 -0
- plain_postgres-0.97.0/tests/app/examples/models/querysets.py +41 -0
- plain_postgres-0.97.0/tests/app/examples/models/relationships.py +44 -0
- plain_postgres-0.97.0/tests/app/examples/models/trees.py +17 -0
- plain_postgres-0.97.0/tests/app/examples/models/unregistered.py +7 -0
- plain_postgres-0.97.0/tests/app/examples/urls.py +36 -0
- plain_postgres-0.97.0/tests/app/examples/views.py +66 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/tests/app/urls.py +3 -4
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/tests/conftest_convergence.py +15 -2
- plain_postgres-0.97.0/tests/test_autodetector_not_null_errors.py +211 -0
- plain_postgres-0.97.0/tests/test_autodetector_type_change.py +105 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/tests/test_connection_lifecycle.py +25 -18
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/tests/test_convergence.py +203 -165
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/tests/test_convergence_constraints.py +365 -241
- plain_postgres-0.97.0/tests/test_convergence_defaults.py +471 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/tests/test_convergence_fk.py +90 -95
- plain_postgres-0.97.0/tests/test_convergence_indexes.py +600 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/tests/test_convergence_nullability.py +85 -46
- plain_postgres-0.97.0/tests/test_convergence_timeouts.py +350 -0
- plain_postgres-0.97.0/tests/test_db_expression_defaults.py +495 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/tests/test_delete_behaviors.py +39 -41
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/tests/test_encrypted_fields.py +1 -1
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/tests/test_exceptions.py +26 -17
- plain_postgres-0.97.0/tests/test_field_defaults.py +146 -0
- plain_postgres-0.97.0/tests/test_functions_uuid.py +31 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/tests/test_introspection.py +177 -45
- plain_postgres-0.97.0/tests/test_iterator.py +101 -0
- plain_postgres-0.97.0/tests/test_literal_default_persistence.py +212 -0
- plain_postgres-0.97.0/tests/test_m2m.py +114 -0
- plain_postgres-0.97.0/tests/test_management_connection.py +105 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/tests/test_manager_assignment.py +1 -1
- plain_postgres-0.97.0/tests/test_mixins.py +19 -0
- plain_postgres-0.97.0/tests/test_modelform_roundtrip.py +295 -0
- plain_postgres-0.97.0/tests/test_no_callable_defaults.py +55 -0
- plain_postgres-0.97.0/tests/test_random_string_field.py +124 -0
- plain_postgres-0.97.0/tests/test_raw_query.py +44 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/tests/test_read_only_transactions.py +16 -16
- plain_postgres-0.95.0/tests/test_related_descriptors.py → plain_postgres-0.97.0/tests/test_related.py +176 -5
- plain_postgres-0.97.0/tests/test_schema_timeouts.py +129 -0
- plain_postgres-0.95.0/plain/postgres/connections.py +0 -98
- plain_postgres-0.95.0/plain/postgres/default_settings.py +0 -38
- plain_postgres-0.95.0/plain/postgres/fields/__init__.py +0 -1937
- plain_postgres-0.95.0/plain/postgres/fields/related_lookups.py +0 -223
- plain_postgres-0.95.0/plain/postgres/migrations/questioner.py +0 -314
- plain_postgres-0.95.0/plain/postgres/test/utils.py +0 -18
- plain_postgres-0.95.0/tests/app/examples/models/__init__.py +0 -139
- plain_postgres-0.95.0/tests/test_convergence_indexes.py +0 -483
- plain_postgres-0.95.0/tests/test_iterator.py +0 -99
- plain_postgres-0.95.0/tests/test_models.py +0 -197
- plain_postgres-0.95.0/tests/test_related_manager_api.py +0 -154
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/.gitignore +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/CLAUDE.md +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/LICENSE +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/README.md +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/aggregates.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/cli/__init__.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/config.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/constants.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/constraints.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/deletion.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/entrypoints.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/exceptions.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/fields/mixins.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/fields/related_descriptors.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/fields/related_managers.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/functions/comparison.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/functions/math.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/functions/text.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/functions/window.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/indexes.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/introspection/health.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/lookups.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/migrations/__init__.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/migrations/executor.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/migrations/graph.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/migrations/loader.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/migrations/migration.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/migrations/operations/__init__.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/migrations/operations/base.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/migrations/operations/models.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/migrations/optimizer.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/migrations/utils.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/migrations/writer.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/options.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/otel.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/preflight.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/registry.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/sql/__init__.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/sql/constants.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/sql/datastructures.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/test/__init__.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/plain/postgres/transaction.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/tests/app/examples/migrations/0001_initial.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/tests/app/examples/migrations/__init__.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/tests/app/settings.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/tests/test_connection_isolation.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/tests/test_database_url.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/tests/test_migration_executor.py +0 -0
- {plain_postgres-0.95.0 → plain_postgres-0.97.0}/tests/test_schema_normalize_type.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: plain.postgres
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.97.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
|
|
@@ -48,7 +48,7 @@ class User(postgres.Model):
|
|
|
48
48
|
email: str = types.EmailField()
|
|
49
49
|
password = PasswordField()
|
|
50
50
|
is_admin: bool = types.BooleanField(default=False)
|
|
51
|
-
created_at: datetime = types.DateTimeField(
|
|
51
|
+
created_at: datetime = types.DateTimeField(create_now=True)
|
|
52
52
|
|
|
53
53
|
def __str__(self) -> str:
|
|
54
54
|
return self.email
|
|
@@ -82,26 +82,63 @@ admin_users = User.query.filter(is_admin=True)
|
|
|
82
82
|
|
|
83
83
|
## Database connection
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
Configure the database with a single URL. The canonical Plain setting is `POSTGRES_URL`:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
# app/settings.py
|
|
89
|
+
POSTGRES_URL = "postgresql://user:password@localhost:5432/dbname"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Or via environment variable:
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
PLAIN_POSTGRES_URL=postgresql://user:password@localhost:5432/dbname
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Plain also reads the `DATABASE_URL` environment variable as a fallback — it's the widely-used convention for Postgres connection strings, so most hosting setups work without extra configuration:
|
|
86
99
|
|
|
87
100
|
```sh
|
|
88
101
|
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
|
|
89
102
|
```
|
|
90
103
|
|
|
91
|
-
|
|
104
|
+
Precedence (highest to lowest): `PLAIN_POSTGRES_URL` → `POSTGRES_URL` in `settings.py` → `DATABASE_URL` environment variable.
|
|
92
105
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
POSTGRES_PASSWORD = "password"
|
|
106
|
+
The URL supports any libpq connection parameter as a query string — for example `?sslmode=require&application_name=web&connect_timeout=10`. These are parsed and passed through to the driver.
|
|
107
|
+
|
|
108
|
+
To explicitly disable the database (e.g. during Docker builds where no database is available), set the URL to the string `none`:
|
|
109
|
+
|
|
110
|
+
```sh
|
|
111
|
+
PLAIN_POSTGRES_URL=none
|
|
100
112
|
```
|
|
101
113
|
|
|
102
|
-
|
|
114
|
+
### Bypassing a connection pooler for management operations
|
|
103
115
|
|
|
104
|
-
|
|
116
|
+
Transaction-mode poolers (PlanetScale, Supabase's pooler, Neon's pooler, standalone pgbouncer in transaction mode) can't run DDL, long transactions, or `pg_dump`. To work around this, set a second URL that management commands use to reach Postgres directly:
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
PLAIN_POSTGRES_URL=postgresql://app@pooler:6432/myapp
|
|
120
|
+
PLAIN_POSTGRES_MANAGEMENT_URL=postgresql://app@postgres:5432/myapp
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
When `POSTGRES_MANAGEMENT_URL` is set, these commands connect through it instead of `POSTGRES_URL`:
|
|
124
|
+
|
|
125
|
+
- `plain migrations create`, `plain migrations apply`, `plain migrations list`, `plain migrations prune`, `plain migrations squash`
|
|
126
|
+
- `plain postgres sync`, `plain postgres converge`, `plain postgres schema`
|
|
127
|
+
- `plain postgres diagnose`, `plain postgres drop-unknown-tables`, `plain postgres shell`
|
|
128
|
+
|
|
129
|
+
When it's unset, all commands use `POSTGRES_URL` — there's no behavior change for existing apps.
|
|
130
|
+
|
|
131
|
+
To route custom code through the management connection, use the `use_management_connection()` context manager:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from plain.postgres import use_management_connection
|
|
135
|
+
|
|
136
|
+
with use_management_connection():
|
|
137
|
+
# Any get_connection() / ORM calls inside this block use POSTGRES_MANAGEMENT_URL.
|
|
138
|
+
run_custom_schema_change()
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
You _can_ point the two URLs at different Postgres roles — e.g. a least-privilege DML role for runtime and a DDL-capable role for management. Plain does not currently automate the grant/ownership plumbing that split requires (default privileges for newly-created tables, ownership reassignment, preflight checks that the runtime role can see the schema). If you adopt that pattern, you're responsible for wiring those up yourself.
|
|
105
142
|
|
|
106
143
|
**PostgreSQL is the only supported database.** You need to install a PostgreSQL driver separately — [psycopg](https://www.psycopg.org/) is recommended:
|
|
107
144
|
|
|
@@ -452,35 +489,42 @@ Schema changes fall into three categories, each with a different author and appl
|
|
|
452
489
|
- **Structural migrations** — tables, columns, renames, column type changes. Framework-generated from the model diff, but you review them and decide when to deploy (a column type change can rewrite the table; a column drop is destructive).
|
|
453
490
|
- **Data migrations** — backfills, transformations, one-time cleanup. Authored by you via `RunPython` or `RunSQL`. The framework only sequences them.
|
|
454
491
|
|
|
455
|
-
| Change | Category | Safe apply pattern
|
|
456
|
-
| ------------------------------------- | -------------------- |
|
|
457
|
-
| Add / drop index | Convergence | `CREATE INDEX CONCURRENTLY`
|
|
458
|
-
| Add / drop unique or check constraint | Convergence | `ADD CONSTRAINT NOT VALID` + `VALIDATE`
|
|
459
|
-
| Add / remove NOT NULL | Convergence | `CHECK NOT VALID` + `VALIDATE` + `SET NOT NULL`
|
|
460
|
-
| Change FK `on_delete` action | Convergence | drop + re-add with `NOT VALID` + `VALIDATE`
|
|
461
|
-
|
|
|
462
|
-
|
|
|
463
|
-
|
|
|
464
|
-
|
|
|
465
|
-
|
|
|
492
|
+
| Change | Category | Safe apply pattern |
|
|
493
|
+
| ------------------------------------- | -------------------- | --------------------------------------------------- |
|
|
494
|
+
| Add / drop index | Convergence | `CREATE INDEX CONCURRENTLY` |
|
|
495
|
+
| Add / drop unique or check constraint | Convergence | `ADD CONSTRAINT NOT VALID` + `VALIDATE` |
|
|
496
|
+
| Add / remove NOT NULL | Convergence | `CHECK NOT VALID` + `VALIDATE` + `SET NOT NULL` |
|
|
497
|
+
| Change FK `on_delete` action | Convergence | drop + re-add with `NOT VALID` + `VALIDATE` |
|
|
498
|
+
| Set / change / drop column `DEFAULT` | Convergence | catalog-only `ALTER COLUMN SET/DROP DEFAULT` |
|
|
499
|
+
| Create / drop table | Structural migration | framework-generated, you review |
|
|
500
|
+
| Add / drop / rename column | Structural migration | framework-generated, you review |
|
|
501
|
+
| Column type change (safe widening) | Structural migration | framework-generated `ALTER TYPE` with implicit cast |
|
|
502
|
+
| Column type change (other) | Data migration | you author (explicit `RunSQL` with `USING`) |
|
|
503
|
+
| Data backfill or transformation | Data migration | you author (`RunPython` / `RunSQL`) |
|
|
504
|
+
| One-time cleanup, seeding | Data migration | you author |
|
|
466
505
|
|
|
467
506
|
**The principle: who authors the change, and can the framework guarantee safety?** If the framework can derive both the change and a universally-safe apply pattern from model definitions, it belongs to convergence. If the framework can generate the DDL but safety depends on context (table size, deploy timing, destructiveness), it's a structural migration — you review it before deploying. If only you know what to do, it's a data migration.
|
|
468
507
|
|
|
469
508
|
Many convergence-managed changes produce DB-enforced behavior — cascading deletes (`ON DELETE`), validation (`CHECK`, `NOT NULL`), default generation. Whether a change is "behavioral" doesn't determine the category; whether the framework can guarantee a safe apply does.
|
|
470
509
|
|
|
471
|
-
| Property
|
|
472
|
-
|
|
|
473
|
-
| Authored by
|
|
474
|
-
| When it runs
|
|
475
|
-
| Drift correction
|
|
476
|
-
| Reversible
|
|
477
|
-
|
|
|
478
|
-
|
|
|
510
|
+
| Property | Convergence | Migrations |
|
|
511
|
+
| ------------------------ | --------------------------------------------------------------------- | ----------------------------------------------------------- |
|
|
512
|
+
| Authored by | Framework (derived from models) | Framework (structural) or you (data) |
|
|
513
|
+
| When it runs | Every `sync`, by diffing models vs database | Once, in recorded timestamp order |
|
|
514
|
+
| Drift correction | Yes — reverts undeclared DB changes on next sync | No — manual DB changes persist |
|
|
515
|
+
| Reversible (intentional) | Implicit — roll back code, re-sync re-derives | No — forward-only, fix-forward |
|
|
516
|
+
| Failure behavior | Per-operation commits — partial progress on failure (re-run to retry) | Batch transaction — failure rolls back the entire migration |
|
|
517
|
+
| Files on disk | None — derived from models live | `.py` files in `migrations/` |
|
|
518
|
+
| Safe DDL | Framework-applied patterns (CONCURRENTLY, NOT VALID + VALIDATE) | Generated DDL; you review before deploy |
|
|
479
519
|
|
|
480
520
|
**Drift correction is a convergence-only behavior.** Convergence re-runs on every `sync` and compares models against the database. An index created manually outside a model declaration will be dropped on the next run because models are the source of truth. Migrations don't behave this way — once applied, they're recorded and never re-applied.
|
|
481
521
|
|
|
482
522
|
**Caveats.** The safety promise isn't absolute. Structural migrations aren't lint-checked yet: adding a column with a volatile default (`gen_random_uuid()`, `now()`) on a large table will rewrite it without warning. Review structural migrations before deploying to production.
|
|
483
523
|
|
|
524
|
+
**Column type changes.** The autodetector only auto-generates `AlterField` for a small allowlist of lossless widenings (`smallint → integer`, `smallint → bigint`, `integer → bigint`) and for parameter-only changes like `max_length`. Every other base-type change rejects with guidance — arbitrary `USING col::newtype` casts either fail at apply time (e.g. timestamp → uuid) or silently corrupt data (e.g. bigint FK → text stringifies PKs), and migrations are forward-only. For anything outside the allowlist, scaffold `plain migrations create --empty --name alter_<model>_<field>_type` and author an explicit `RunSQL` with a `USING` expression you've reviewed.
|
|
525
|
+
|
|
526
|
+
"Safe" here means data-integrity safe, not operationally cheap. An `ALTER COLUMN ... TYPE` that changes on-disk width (any of the allowlisted widenings) takes `ACCESS EXCLUSIVE` and rewrites the table — on a large table this can block writes for minutes. Deploy these during a maintenance window, not in the middle of traffic.
|
|
527
|
+
|
|
484
528
|
**Out of scope for convergence.** Triggers, views, stored procedures, and other non-standard DDL stay outside convergence — it won't create them from models, and it won't drop them if they exist. Manage them with `RunSQL` data migrations.
|
|
485
529
|
|
|
486
530
|
### Syncing
|
|
@@ -689,6 +733,43 @@ Some changes can't be applied automatically. For example, if you add `NOT NULL`
|
|
|
689
733
|
|
|
690
734
|
When you remove an index or constraint from a model, convergence automatically drops the undeclared database object on the next `postgres sync`. Models are the source of truth — if it's not declared, it gets removed.
|
|
691
735
|
|
|
736
|
+
### DDL timeouts
|
|
737
|
+
|
|
738
|
+
Every framework-issued DDL statement — both in migrations and in convergence — is wrapped with `lock_timeout` and `statement_timeout` so a deploy can't hang indefinitely waiting for a lock, and so a backfill against an unexpectedly large table fails fast instead of holding `ACCESS EXCLUSIVE` for minutes.
|
|
739
|
+
|
|
740
|
+
```python
|
|
741
|
+
# app/settings.py — defaults shown
|
|
742
|
+
POSTGRES_MIGRATION_LOCK_TIMEOUT = "3s"
|
|
743
|
+
POSTGRES_MIGRATION_STATEMENT_TIMEOUT = "3s"
|
|
744
|
+
POSTGRES_CONVERGENCE_LOCK_TIMEOUT = "3s"
|
|
745
|
+
POSTGRES_CONVERGENCE_STATEMENT_TIMEOUT = "3s"
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
`lock_timeout` applies to every DDL. `statement_timeout` applies only to statements that take `ACCESS EXCLUSIVE` — non-blocking operations (`CREATE INDEX CONCURRENTLY`, `VALIDATE CONSTRAINT`) run unbounded because they can't cascade the lock queue.
|
|
749
|
+
|
|
750
|
+
If a migration issues a row-touching UPDATE (e.g. a hand-written `RunPython` or `RunSQL` backfill), the 3s `statement_timeout` will kill it on any non-tiny table. That's intentional — the right fix is a batched data migration, not a single long-running UPDATE. The common first-time failure mode is applying migrations against a pre-seeded dev or staging database: raise the ceiling for that one run, then lower it back for production deploys.
|
|
751
|
+
|
|
752
|
+
Use `RunSQL(no_timeout=True)` to opt out for a specific operation:
|
|
753
|
+
|
|
754
|
+
```python
|
|
755
|
+
from plain.postgres.migrations.operations import RunSQL
|
|
756
|
+
|
|
757
|
+
operations = [
|
|
758
|
+
RunSQL(
|
|
759
|
+
"UPDATE orders SET status = 'pending' WHERE status IS NULL",
|
|
760
|
+
no_timeout=True,
|
|
761
|
+
),
|
|
762
|
+
]
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
Non-atomic migrations (`Migration.atomic = False`, used for `CREATE INDEX CONCURRENTLY` in a migration) skip the timeout prelude automatically — `SET LOCAL` is a no-op outside a transaction block. Manage timeouts inside your own `RunSQL` if you need them.
|
|
766
|
+
|
|
767
|
+
Environment overrides: every setting accepts `PLAIN_POSTGRES_*` env vars, so you can raise the ceiling for a specific deploy without a code change:
|
|
768
|
+
|
|
769
|
+
```bash
|
|
770
|
+
PLAIN_POSTGRES_MIGRATION_STATEMENT_TIMEOUT=30s plain migrations apply
|
|
771
|
+
```
|
|
772
|
+
|
|
692
773
|
## Fields
|
|
693
774
|
|
|
694
775
|
You can use many field types for different data:
|
|
@@ -713,8 +794,8 @@ class Product(postgres.Model):
|
|
|
713
794
|
is_active: bool = types.BooleanField(default=True)
|
|
714
795
|
|
|
715
796
|
# Date and time fields
|
|
716
|
-
created_at: datetime = types.DateTimeField(
|
|
717
|
-
updated_at: datetime = types.DateTimeField(
|
|
797
|
+
created_at: datetime = types.DateTimeField(create_now=True)
|
|
798
|
+
updated_at: datetime = types.DateTimeField(update_now=True)
|
|
718
799
|
```
|
|
719
800
|
|
|
720
801
|
**Text fields:**
|
|
@@ -742,10 +823,11 @@ class Product(postgres.Model):
|
|
|
742
823
|
**Other fields:**
|
|
743
824
|
|
|
744
825
|
- [`BooleanField`](./fields/__init__.py#BooleanField) - True/False
|
|
745
|
-
- [`UUIDField`](./fields/__init__.py#UUIDField) - UUID
|
|
826
|
+
- [`UUIDField`](./fields/__init__.py#UUIDField) - UUID (pass `generate=True` for a per-row `gen_random_uuid()` default)
|
|
746
827
|
- [`BinaryField`](./fields/__init__.py#BinaryField) - Raw binary data
|
|
747
828
|
- [`JSONField`](./fields/json.py#JSONField) - JSON data
|
|
748
829
|
- [`GenericIPAddressField`](./fields/__init__.py#GenericIPAddressField) - IPv4 or IPv6 address
|
|
830
|
+
- [`RandomStringField`](./fields/text.py#RandomStringField) - Per-row random hex string generated by Postgres (`length=`) — use for tokens, slugs, short IDs instead of a Python callable default. Slices `gen_random_uuid()` directly; values are uniform hex characters
|
|
749
831
|
|
|
750
832
|
**Encrypted fields:**
|
|
751
833
|
|
|
@@ -775,8 +857,8 @@ from plain.postgres import types
|
|
|
775
857
|
|
|
776
858
|
# Regular Python class for shared fields
|
|
777
859
|
class TimestampedMixin:
|
|
778
|
-
created_at: datetime = types.DateTimeField(
|
|
779
|
-
updated_at: datetime = types.DateTimeField(
|
|
860
|
+
created_at: datetime = types.DateTimeField(create_now=True)
|
|
861
|
+
updated_at: datetime = types.DateTimeField(update_now=True)
|
|
780
862
|
|
|
781
863
|
|
|
782
864
|
# Models inherit from the mixin AND postgres.Model
|
|
@@ -1161,23 +1243,18 @@ These are static, code-level checks that catch issues before you deploy. The `di
|
|
|
1161
1243
|
|
|
1162
1244
|
## Settings
|
|
1163
1245
|
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
When `DATABASE_URL` is set, it is parsed into the individual connection settings automatically. When `DATABASE_URL` is not set, the connection settings are required individually.
|
|
1246
|
+
The connection is configured with a single URL (`POSTGRES_URL`). `DATABASE_URL` is read as a platform-compat fallback. Set the URL to `none` to explicitly disable the database (e.g. during Docker image builds).
|
|
1167
1247
|
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
|
1171
|
-
|
|
|
1172
|
-
| `
|
|
1173
|
-
| `
|
|
1174
|
-
| `
|
|
1175
|
-
| `
|
|
1176
|
-
| `
|
|
1177
|
-
| `
|
|
1178
|
-
| `POSTGRES_CONN_HEALTH_CHECKS` | `bool` | `True` | `PLAIN_POSTGRES_CONN_HEALTH_CHECKS` |
|
|
1179
|
-
| `POSTGRES_OPTIONS` | `dict` | `{}` | — |
|
|
1180
|
-
| `POSTGRES_TIME_ZONE` | `str \| None` | `None` | `PLAIN_POSTGRES_TIME_ZONE` |
|
|
1248
|
+
| Setting | Type | Default | Env var |
|
|
1249
|
+
| ---------------------------------------- | ------------- | ----------------------- | ---------------------------------------------- |
|
|
1250
|
+
| `POSTGRES_URL` | `Secret[str]` | `$DATABASE_URL` or `""` | `PLAIN_POSTGRES_URL` |
|
|
1251
|
+
| `POSTGRES_MANAGEMENT_URL` | `Secret[str]` | `""` | `PLAIN_POSTGRES_MANAGEMENT_URL` |
|
|
1252
|
+
| `POSTGRES_CONN_MAX_AGE` | `int` | `600` | `PLAIN_POSTGRES_CONN_MAX_AGE` |
|
|
1253
|
+
| `POSTGRES_CONN_HEALTH_CHECKS` | `bool` | `True` | `PLAIN_POSTGRES_CONN_HEALTH_CHECKS` |
|
|
1254
|
+
| `POSTGRES_MIGRATION_LOCK_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_MIGRATION_LOCK_TIMEOUT` |
|
|
1255
|
+
| `POSTGRES_MIGRATION_STATEMENT_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_MIGRATION_STATEMENT_TIMEOUT` |
|
|
1256
|
+
| `POSTGRES_CONVERGENCE_LOCK_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_CONVERGENCE_LOCK_TIMEOUT` |
|
|
1257
|
+
| `POSTGRES_CONVERGENCE_STATEMENT_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_CONVERGENCE_STATEMENT_TIMEOUT` |
|
|
1181
1258
|
|
|
1182
1259
|
See [`default_settings.py`](./default_settings.py) for more details.
|
|
1183
1260
|
|
|
@@ -1185,7 +1262,19 @@ See [`default_settings.py`](./default_settings.py) for more details.
|
|
|
1185
1262
|
|
|
1186
1263
|
#### How do I add a field to an existing model?
|
|
1187
1264
|
|
|
1188
|
-
Add the field to your model class, then run `plain migrations create` to create a migration.
|
|
1265
|
+
Add the field to your model class, then run `plain migrations create` to create a migration.
|
|
1266
|
+
|
|
1267
|
+
If the field is required (no `default=` and not `allow_null=True`), the autodetector refuses to generate the migration, since there's no value to seed existing rows with. You have two options:
|
|
1268
|
+
|
|
1269
|
+
1. Declare a `default=` on the field so the new column has a value for existing rows.
|
|
1270
|
+
2. Add the field with `allow_null=True`, scaffold a data migration with `plain migrations create --empty --name backfill_<field>` to populate existing rows, then remove `allow_null=True` from the field — convergence applies `NOT NULL` on the next `postgres sync`.
|
|
1271
|
+
|
|
1272
|
+
#### How do I make an existing column `NOT NULL`?
|
|
1273
|
+
|
|
1274
|
+
Edit the field to remove `allow_null=True`. `plain migrations create` won't detect a schema change — nullability is managed by convergence. Run `plain postgres sync`:
|
|
1275
|
+
|
|
1276
|
+
- If the column has no `NULL` rows, convergence applies the change with a non-blocking `CHECK NOT VALID` + `VALIDATE` + `SET NOT NULL` pattern.
|
|
1277
|
+
- If `NULL` rows exist, convergence blocks and prints the table and column to backfill. Scaffold a data migration with `plain migrations create --empty --name backfill_<field>`, write the backfill, and run `postgres sync` again.
|
|
1189
1278
|
|
|
1190
1279
|
#### How do I create a unique constraint on multiple fields?
|
|
1191
1280
|
|
|
@@ -1,5 +1,85 @@
|
|
|
1
1
|
# plain-postgres changelog
|
|
2
2
|
|
|
3
|
+
## [0.97.0](https://github.com/dropseed/plain/releases/plain-postgres@0.97.0) (2026-04-21)
|
|
4
|
+
|
|
5
|
+
### What's changed
|
|
6
|
+
|
|
7
|
+
- **Replaced individual `POSTGRES_*` connection fields with a single `POSTGRES_URL` setting.** `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DATABASE`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_OPTIONS`, and `POSTGRES_TIME_ZONE` are gone — configure the connection with one URL (e.g. `postgresql://user:pass@host:5432/db?sslmode=require`). `DATABASE_URL` is still read as a fallback. Set the URL to `none` to explicitly disable the database (e.g. during Docker image builds). ([770a74606463](https://github.com/dropseed/plain/commit/770a74606463))
|
|
8
|
+
- **Added `POSTGRES_MANAGEMENT_URL` for routing DDL through a separate connection.** When set, `plain migrations create|apply|list|prune|squash`, `plain postgres sync|converge|schema|diagnose|drop-unknown-tables|shell` connect through this URL instead of `POSTGRES_URL`. Use it to bypass transaction-mode poolers (PlanetScale, Supabase's pooler, Neon's pooler, pgbouncer) for schema changes, long transactions, and `pg_dump`. A new `use_management_connection()` context manager routes custom code through the same connection. When unset, all commands use `POSTGRES_URL` — no behavior change for existing apps. ([d1cc9630d049](https://github.com/dropseed/plain/commit/d1cc9630d049))
|
|
9
|
+
- **Extracted the test-database lifecycle off `DatabaseConnection`.** Test setup/teardown now lives in `plain.postgres.test` instead of coupling it to the runtime connection class. ([ea67f82c746c](https://github.com/dropseed/plain/commit/ea67f82c746c))
|
|
10
|
+
- **Removed thin psycopg re-export wrappers.** Internal code now imports directly from `psycopg` rather than the redundant Plain-level passthroughs. ([d1cb74100e0d](https://github.com/dropseed/plain/commit/d1cb74100e0d))
|
|
11
|
+
|
|
12
|
+
### Upgrade instructions
|
|
13
|
+
|
|
14
|
+
- **Replace individual `POSTGRES_*` settings with `POSTGRES_URL`** in `app/settings.py` (or `PLAIN_POSTGRES_URL` in the environment). For example:
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
# Before
|
|
18
|
+
POSTGRES_HOST = "localhost"
|
|
19
|
+
POSTGRES_PORT = 5432
|
|
20
|
+
POSTGRES_DATABASE = "myapp"
|
|
21
|
+
POSTGRES_USER = "app"
|
|
22
|
+
POSTGRES_PASSWORD = "secret"
|
|
23
|
+
|
|
24
|
+
# After
|
|
25
|
+
POSTGRES_URL = "postgresql://app:secret@localhost:5432/myapp"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Apps that already set `DATABASE_URL` in the environment don't need any change.
|
|
29
|
+
|
|
30
|
+
- **If `POSTGRES_OPTIONS` or `POSTGRES_TIME_ZONE` were set**, move them into the URL as query parameters (e.g. `?application_name=web&timezone=UTC`).
|
|
31
|
+
- **If you run behind a transaction-mode pooler**, consider setting `POSTGRES_MANAGEMENT_URL` to a direct-to-Postgres connection string so `plain migrations` and `plain postgres sync` can issue DDL.
|
|
32
|
+
|
|
33
|
+
## [0.96.0](https://github.com/dropseed/plain/releases/plain-postgres@0.96.0) (2026-04-17)
|
|
34
|
+
|
|
35
|
+
### What's changed
|
|
36
|
+
|
|
37
|
+
- **`DateTimeField` gained `create_now=True` / `update_now=True` kwargs; `auto_now_add` and `auto_now` are removed.** `create_now=True` installs a persistent `DEFAULT STATEMENT_TIMESTAMP()` column default — raw-SQL inserts now get a value, not just ORM-driven ones. `update_now=True` stamps the column on every `save()` via `pre_save`. Preflight requires `update_now=True` to be paired with `create_now=True` or `allow_null=True` so existing rows have a backfill path. `default=` is no longer accepted on `DateTimeField`. ([5d145e4](https://github.com/dropseed/plain/commit/5d145e4), [a44e5ec](https://github.com/dropseed/plain/commit/a44e5ec), [091bac7](https://github.com/dropseed/plain/commit/091bac7))
|
|
38
|
+
- **`UUIDField` gained `generate=True`; `default=GenRandomUUID()` is no longer accepted.** `generate=True` installs `DEFAULT gen_random_uuid()` on the column, so Postgres produces a fresh UUID per row (raw-SQL inserts included). ([a44e5ec](https://github.com/dropseed/plain/commit/a44e5ec))
|
|
39
|
+
- **Added `RandomStringField(length=N)`** for per-row DB-generated random hex strings. Backed by a `DEFAULT` that slices `gen_random_uuid()::text`; use in place of Python `default=secrets.token_hex` callables for tokens, slugs, and short IDs. Alphabet is always hex — an earlier draft accepted `alphabet=` but it was dropped because the generated expression grew to ~4 KB for a 40-char token. ([34858ab](https://github.com/dropseed/plain/commit/34858ab), [0918702](https://github.com/dropseed/plain/commit/0918702))
|
|
40
|
+
- **Added `GenRandomUUID()` function.** Exported at `plain.postgres.functions.GenRandomUUID`. No longer valid as `default=`; use `UUIDField(generate=True)` or reference it in annotations/expressions. ([da58230](https://github.com/dropseed/plain/commit/da58230))
|
|
41
|
+
- **Callable `default=` is banned on model fields.** `default=uuid.uuid4`, `default=secrets.token_hex`, `default=dict`, `default=lambda: ...`, etc. raise `TypeError` at field construction. Use DB-side generation (`UUIDField(generate=True)`, `RandomStringField`, `DateTimeField(create_now=True)`) or a static literal. Empty-collection defaults use literal `{}` / `[]` — the value is deep-copied on each `get_default()` call. ([091bac7](https://github.com/dropseed/plain/commit/091bac7))
|
|
42
|
+
- **Literal `default=X` values now persist as column `DEFAULT` in the catalog and are reconciled by convergence.** Previously `default=` was Python-side only; now it is compiled to a DDL `DEFAULT <literal>` clause. Raw-SQL `INSERT`s get the default, and drift is detected if someone edits it out-of-band. ([c59473d](https://github.com/dropseed/plain/commit/c59473d), [6ed95fe](https://github.com/dropseed/plain/commit/6ed95fe), [161c7f9](https://github.com/dropseed/plain/commit/161c7f9))
|
|
43
|
+
- **Column nullability and DEFAULT transitions now go through convergence, not the schema editor.** `AlterField` is a no-op when only `allow_null` or `default=` changed; `plain postgres sync` applies the change with online-safe DDL (`CHECK NOT VALID` + `VALIDATE` + `SET NOT NULL` for NOT NULL flips; catalog-only `SET`/`DROP DEFAULT` for default changes). The old 4-way NULL → NOT NULL backfill in the schema editor is gone — if a column has NULL rows, convergence now blocks with guidance instead of silently backfilling. ([3e10ab2](https://github.com/dropseed/plain/commit/3e10ab2), [c59473d](https://github.com/dropseed/plain/commit/c59473d))
|
|
44
|
+
- **Every framework-issued DDL statement now emits `SET LOCAL lock_timeout` and, where relevant, `SET LOCAL statement_timeout`.** Defaults are `3s` each and apply to both migration operations and convergence fixes. Non-blocking operations (`CREATE INDEX CONCURRENTLY`, `VALIDATE CONSTRAINT`) skip `statement_timeout`. Configure via new settings `POSTGRES_MIGRATION_LOCK_TIMEOUT`, `POSTGRES_MIGRATION_STATEMENT_TIMEOUT`, `POSTGRES_CONVERGENCE_LOCK_TIMEOUT`, `POSTGRES_CONVERGENCE_STATEMENT_TIMEOUT` (all `PLAIN_POSTGRES_*` env-var compatible). `RunSQL(no_timeout=True)` opts a single operation out — useful for batched backfills that manage their own timeouts. ([11d903b](https://github.com/dropseed/plain/commit/11d903b))
|
|
45
|
+
- **The autodetector rejects unsafe column type changes.** Base-type changes outside a lossless widening allowlist (`smallint → integer`, `smallint → bigint`, `integer → bigint`) raise `MigrationSchemaError` with scaffold guidance instead of emitting an `AlterField` that would compile to a blind `ALTER COLUMN ... TYPE ... USING col::newtype`. Parameter-only changes (e.g. `max_length`) and the widening allowlist still auto-generate. ([073a9af](https://github.com/dropseed/plain/commit/073a9af))
|
|
46
|
+
- **The autodetector rejects adding a NOT NULL column without a default.** Previously Plain prompted interactively for a one-shot value; now the autodetector errors out with two remediation options: declare a `default=`, or add the field as nullable, backfill, and drop `allow_null=True` via convergence. The `MigrationQuestioner.ask_not_null_*` prompts are gone. ([091bac7](https://github.com/dropseed/plain/commit/091bac7))
|
|
47
|
+
- **`AddField` / `AlterField` no longer accept `preserve_default`.** The argument is removed from both operation classes and from `ProjectState.add_field` / `alter_field`. Existing migration files that pass it will fail to load — regenerate them or remove the kwarg. ([c0a117f](https://github.com/dropseed/plain/commit/c0a117f))
|
|
48
|
+
- **Backslashes are banned in string `default=` values.** `default=r"C:\path"` raises `ValueError` at construction to prevent spurious DEFAULT drift on every convergence run. ([f8b6227](https://github.com/dropseed/plain/commit/f8b6227))
|
|
49
|
+
- **`choices=` is now only accepted on `TextField` (and `TimeZoneField`).** Other fields (`IntegerField`, `BooleanField`, etc.) reject `choices=` at call time. ([01584dc](https://github.com/dropseed/plain/commit/01584dc))
|
|
50
|
+
- **Removed `IntegerChoices` and the `Choices` base class.** Only `TextChoices` remains; it now subclasses `str, enum.Enum` directly. ([96acf13](https://github.com/dropseed/plain/commit/96acf13))
|
|
51
|
+
- **`max_length=` is now only accepted on `TextField`, `BinaryField`, and `EncryptedTextField`.** Other fields reject it. ([aaa0fb6](https://github.com/dropseed/plain/commit/aaa0fb6))
|
|
52
|
+
- **`default=` is no longer accepted on `ForeignKeyField`, `ManyToManyField`, `BinaryField`, `EncryptedTextField`, or `EncryptedJSONField`.** ([60299dc](https://github.com/dropseed/plain/commit/60299dc), [99ba5c2](https://github.com/dropseed/plain/commit/99ba5c2))
|
|
53
|
+
- **`ManyToManyField` signature is now explicit** — it rejects `required=`, `allow_null=`, `default=`, and `validators=` with `TypeError`. ([be7fd86](https://github.com/dropseed/plain/commit/be7fd86))
|
|
54
|
+
- **Removed `error_messages=` from model fields and `ModelForm.Meta`.** Form-field `error_messages` is unchanged; this only affects the model layer. ([4dee5ec](https://github.com/dropseed/plain/commit/4dee5ec))
|
|
55
|
+
- **`PrimaryKeyField` takes no arguments.** It is always `bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL`. Removed kwargs for `required`, `allow_null`, `default`, and `validators`; the type stub now matches the runtime signature. ([ca122c9](https://github.com/dropseed/plain/commit/ca122c9), [0ecd71e](https://github.com/dropseed/plain/commit/0ecd71e))
|
|
56
|
+
- **`plain postgres sync --check` now prints pending work.** Previously `--check` only exited non-zero; it now enumerates pending migrations, convergence items, and blocked items with guidance. ([0de289d](https://github.com/dropseed/plain/commit/0de289d))
|
|
57
|
+
- **Fixed index drift false positive for `DESC` / `NULLS FIRST|LAST` columns.** Indexes like `Index(fields=["-created_at"])` were rebuilt on every `postgres sync` because the introspection parser misread the sort direction as an opclass. ([07cb500](https://github.com/dropseed/plain/commit/07cb500))
|
|
58
|
+
- **Fixed `Field.deconstruct()` over-shortening import paths** — `plain.postgres.fields.<submod>.X` now shortens to `plain.postgres.X` only when `X` is actually re-exported at the top level. ([34858ab](https://github.com/dropseed/plain/commit/34858ab))
|
|
59
|
+
- **`ModelForm` no longer marks DB-expression-default and auto-filled fields as `required`.** Fields with `db_returning=True` (e.g. `create_now=True`, `generate=True`, `RandomStringField`) and `auto_fills_on_save=True` (`update_now=True`) produce form fields with `required=False` and preserve the `DATABASE_DEFAULT` sentinel through `construct_instance` so INSERT emits `DEFAULT` instead of NULL on empty submissions. `modelfield_to_formfield` now returns `None` for non-column-backed fields (M2M, etc.). ([6ed95fe](https://github.com/dropseed/plain/commit/6ed95fe))
|
|
60
|
+
- **Internal restructuring.** `Field` is split into `ColumnField` → `DefaultableField` → `ChoicesField` with kwargs scoped to the fields that actually accept them. `plain.postgres.fields.__init__` is split into per-type modules (`base`, `text`, `numeric`, `temporal`, `boolean`, `binary`, `uuid`, `network`, `duration`, `primary_key`). `PrimaryKeyField` moved off the `BigIntegerField → IntegerField` chain onto `ColumnField[int]` directly. `non_db_attrs` renamed to `non_migration_attrs`. Removed dead Django-era internals: `SubqueryConstraint`, `MultiColSource`, multi-column FK machinery, multi-table-inheritance UPDATE machinery, `Field.description`, `Field.value_to_string()`, `Field.get_limit_choices_to()`. ([476e1ae](https://github.com/dropseed/plain/commit/476e1ae), [9ed8cc6](https://github.com/dropseed/plain/commit/9ed8cc6), [ca122c9](https://github.com/dropseed/plain/commit/ca122c9), [21cf85f](https://github.com/dropseed/plain/commit/21cf85f), [18080ca](https://github.com/dropseed/plain/commit/18080ca), [9d4ff49](https://github.com/dropseed/plain/commit/9d4ff49), [07b5f0b](https://github.com/dropseed/plain/commit/07b5f0b), [176f56e](https://github.com/dropseed/plain/commit/176f56e), [16e4fcd](https://github.com/dropseed/plain/commit/16e4fcd), [cb98bfa](https://github.com/dropseed/plain/commit/cb98bfa))
|
|
61
|
+
|
|
62
|
+
### Upgrade instructions
|
|
63
|
+
|
|
64
|
+
- **Replace `auto_now_add=True` with `create_now=True`** on every `DateTimeField`.
|
|
65
|
+
- **Replace `auto_now=True` with `update_now=True`**. If the field was `NOT NULL`, also set `create_now=True` (or `allow_null=True`) — preflight will fail otherwise.
|
|
66
|
+
- **Replace `DateTimeField(default=timezone.now)` / `default=Now()`** with `DateTimeField(create_now=True)`. `DateTimeField(default=...)` is no longer accepted.
|
|
67
|
+
- **Replace `UUIDField(default=uuid.uuid4)` and `UUIDField(default=GenRandomUUID())`** with `UUIDField(generate=True)`. `UUIDField(default=...)` is no longer accepted.
|
|
68
|
+
- **Replace `default=secrets.token_hex` / `default=secrets.token_urlsafe`** with `RandomStringField(length=N)` (hex output only).
|
|
69
|
+
- **Replace `default=dict` / `default=list`** with `default={}` / `default=[]`. Any other callable passed as `default=` will now raise `TypeError`.
|
|
70
|
+
- **Remove `choices=` from non-text fields** (`IntegerField`, `BooleanField`, etc.).
|
|
71
|
+
- **Replace `IntegerChoices` usages** with `TextChoices` or a plain `enum.IntEnum`. `Choices` (the base class) is also gone.
|
|
72
|
+
- **Remove `max_length=` from any field that isn't `TextField`, `BinaryField`, or `EncryptedTextField`.**
|
|
73
|
+
- **Remove `default=` from `ForeignKeyField`, `BinaryField`, `EncryptedTextField`, and `EncryptedJSONField`.**
|
|
74
|
+
- **Remove `required=`, `allow_null=`, `default=`, and `validators=` from `ManyToManyField`** — its signature is now explicit (`to`, `through`, `through_fields`, `related_query_name`, `limit_choices_to`, `symmetrical`).
|
|
75
|
+
- **Remove kwargs from `PrimaryKeyField()`** — it no longer accepts any.
|
|
76
|
+
- **Remove `error_messages=` from model-level fields and `ModelForm.Meta`.** (Form-field `error_messages` on standalone form fields is unchanged.)
|
|
77
|
+
- **Escape backslashes in string `default=` values.** `default="C:\\path"` is fine; `default=r"C:\path"` now raises at construction.
|
|
78
|
+
- **Edit or regenerate migration files that pass `preserve_default=...`** to `AddField` / `AlterField` — the kwarg was removed.
|
|
79
|
+
- **Rename `non_db_attrs` to `non_migration_attrs`** in any custom field subclass.
|
|
80
|
+
- **If your migrations hit the new 3s `statement_timeout`** against a large dev/staging DB, raise it for that run via `PLAIN_POSTGRES_MIGRATION_STATEMENT_TIMEOUT=30s`, or pass `RunSQL(sql, no_timeout=True)` on individual long-running operations.
|
|
81
|
+
- **Run `plain postgres sync`** after upgrading to let convergence install persisted column DEFAULTs on existing tables.
|
|
82
|
+
|
|
3
83
|
## [0.95.0](https://github.com/dropseed/plain/releases/plain-postgres@0.95.0) (2026-04-14)
|
|
4
84
|
|
|
5
85
|
### What's changed
|