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