plain.postgres 0.84.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. plain/postgres/CHANGELOG.md +1028 -0
  2. plain/postgres/README.md +925 -0
  3. plain/postgres/__init__.py +120 -0
  4. plain/postgres/agents/.claude/rules/plain-postgres.md +78 -0
  5. plain/postgres/aggregates.py +236 -0
  6. plain/postgres/backups/__init__.py +0 -0
  7. plain/postgres/backups/cli.py +148 -0
  8. plain/postgres/backups/clients.py +94 -0
  9. plain/postgres/backups/core.py +172 -0
  10. plain/postgres/base.py +1415 -0
  11. plain/postgres/cli/__init__.py +3 -0
  12. plain/postgres/cli/db.py +142 -0
  13. plain/postgres/cli/migrations.py +1085 -0
  14. plain/postgres/config.py +18 -0
  15. plain/postgres/connection.py +1331 -0
  16. plain/postgres/connections.py +77 -0
  17. plain/postgres/constants.py +13 -0
  18. plain/postgres/constraints.py +495 -0
  19. plain/postgres/database_url.py +94 -0
  20. plain/postgres/db.py +59 -0
  21. plain/postgres/default_settings.py +38 -0
  22. plain/postgres/deletion.py +475 -0
  23. plain/postgres/dialect.py +640 -0
  24. plain/postgres/entrypoints.py +4 -0
  25. plain/postgres/enums.py +103 -0
  26. plain/postgres/exceptions.py +217 -0
  27. plain/postgres/expressions.py +1912 -0
  28. plain/postgres/fields/__init__.py +2118 -0
  29. plain/postgres/fields/encrypted.py +354 -0
  30. plain/postgres/fields/json.py +413 -0
  31. plain/postgres/fields/mixins.py +30 -0
  32. plain/postgres/fields/related.py +1192 -0
  33. plain/postgres/fields/related_descriptors.py +290 -0
  34. plain/postgres/fields/related_lookups.py +223 -0
  35. plain/postgres/fields/related_managers.py +661 -0
  36. plain/postgres/fields/reverse_descriptors.py +229 -0
  37. plain/postgres/fields/reverse_related.py +328 -0
  38. plain/postgres/fields/timezones.py +143 -0
  39. plain/postgres/forms.py +773 -0
  40. plain/postgres/functions/__init__.py +189 -0
  41. plain/postgres/functions/comparison.py +127 -0
  42. plain/postgres/functions/datetime.py +454 -0
  43. plain/postgres/functions/math.py +140 -0
  44. plain/postgres/functions/mixins.py +59 -0
  45. plain/postgres/functions/text.py +282 -0
  46. plain/postgres/functions/window.py +125 -0
  47. plain/postgres/indexes.py +286 -0
  48. plain/postgres/lookups.py +758 -0
  49. plain/postgres/meta.py +584 -0
  50. plain/postgres/migrations/__init__.py +53 -0
  51. plain/postgres/migrations/autodetector.py +1379 -0
  52. plain/postgres/migrations/exceptions.py +54 -0
  53. plain/postgres/migrations/executor.py +188 -0
  54. plain/postgres/migrations/graph.py +364 -0
  55. plain/postgres/migrations/loader.py +377 -0
  56. plain/postgres/migrations/migration.py +180 -0
  57. plain/postgres/migrations/operations/__init__.py +34 -0
  58. plain/postgres/migrations/operations/base.py +139 -0
  59. plain/postgres/migrations/operations/fields.py +373 -0
  60. plain/postgres/migrations/operations/models.py +798 -0
  61. plain/postgres/migrations/operations/special.py +184 -0
  62. plain/postgres/migrations/optimizer.py +74 -0
  63. plain/postgres/migrations/questioner.py +340 -0
  64. plain/postgres/migrations/recorder.py +119 -0
  65. plain/postgres/migrations/serializer.py +378 -0
  66. plain/postgres/migrations/state.py +882 -0
  67. plain/postgres/migrations/utils.py +147 -0
  68. plain/postgres/migrations/writer.py +302 -0
  69. plain/postgres/options.py +207 -0
  70. plain/postgres/otel.py +231 -0
  71. plain/postgres/preflight.py +336 -0
  72. plain/postgres/query.py +2242 -0
  73. plain/postgres/query_utils.py +456 -0
  74. plain/postgres/registry.py +217 -0
  75. plain/postgres/schema.py +1885 -0
  76. plain/postgres/sql/__init__.py +40 -0
  77. plain/postgres/sql/compiler.py +1869 -0
  78. plain/postgres/sql/constants.py +22 -0
  79. plain/postgres/sql/datastructures.py +222 -0
  80. plain/postgres/sql/query.py +2947 -0
  81. plain/postgres/sql/where.py +374 -0
  82. plain/postgres/test/__init__.py +0 -0
  83. plain/postgres/test/pytest.py +117 -0
  84. plain/postgres/test/utils.py +18 -0
  85. plain/postgres/transaction.py +222 -0
  86. plain/postgres/types.py +92 -0
  87. plain/postgres/types.pyi +751 -0
  88. plain/postgres/utils.py +345 -0
  89. plain_postgres-0.84.0.dist-info/METADATA +937 -0
  90. plain_postgres-0.84.0.dist-info/RECORD +93 -0
  91. plain_postgres-0.84.0.dist-info/WHEEL +4 -0
  92. plain_postgres-0.84.0.dist-info/entry_points.txt +5 -0
  93. plain_postgres-0.84.0.dist-info/licenses/LICENSE +61 -0
@@ -0,0 +1,937 @@
1
+ Metadata-Version: 2.4
2
+ Name: plain.postgres
3
+ Version: 0.84.0
4
+ Summary: Model your data and store it in a database.
5
+ Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
+ License-Expression: BSD-3-Clause
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.13
9
+ Requires-Dist: plain<1.0.0
10
+ Requires-Dist: sqlparse>=0.3.1
11
+ Description-Content-Type: text/markdown
12
+
13
+ # plain.postgres
14
+
15
+ **Model your data and store it in a database.**
16
+
17
+ - [Overview](#overview)
18
+ - [Database connection](#database-connection)
19
+ - [Querying](#querying)
20
+ - [Migrations](#migrations)
21
+ - [Fields](#fields)
22
+ - [Relationships](#relationships)
23
+ - [Constraints](#constraints)
24
+ - [Forms](#forms)
25
+ - [Architecture](#architecture)
26
+ - [Settings](#settings)
27
+ - [FAQs](#faqs)
28
+ - [Installation](#installation)
29
+
30
+ ## Overview
31
+
32
+ ```python
33
+ # app/users/models.py
34
+ from datetime import datetime
35
+
36
+ from plain import postgres
37
+ from plain.postgres import types
38
+ from plain.passwords.models import PasswordField
39
+
40
+
41
+ @postgres.register_model
42
+ class User(postgres.Model):
43
+ email: str = types.EmailField()
44
+ password = PasswordField()
45
+ is_admin: bool = types.BooleanField(default=False)
46
+ created_at: datetime = types.DateTimeField(auto_now_add=True)
47
+
48
+ def __str__(self) -> str:
49
+ return self.email
50
+ ```
51
+
52
+ Every model automatically includes an `id` field which serves as the primary
53
+ key. The name `id` is reserved and can't be used for other fields.
54
+
55
+ You can create, update, and delete instances of your models:
56
+
57
+ ```python
58
+ from .models import User
59
+
60
+
61
+ # Create a new user
62
+ user = User.query.create(
63
+ email="test@example.com",
64
+ password="password",
65
+ )
66
+
67
+ # Update a user
68
+ user.email = "new@example.com"
69
+ user.save()
70
+
71
+ # Delete a user
72
+ user.delete()
73
+
74
+ # Query for users
75
+ admin_users = User.query.filter(is_admin=True)
76
+ ```
77
+
78
+ ## Database connection
79
+
80
+ To connect to a database, you can provide a `DATABASE_URL` environment variable:
81
+
82
+ ```sh
83
+ DATABASE_URL=postgresql://user:password@localhost:5432/dbname
84
+ ```
85
+
86
+ Or you can set the individual `POSTGRES_*` settings (via `PLAIN_POSTGRES_*` environment variables or in `app/settings.py`):
87
+
88
+ ```python
89
+ # app/settings.py
90
+ POSTGRES_HOST = "localhost"
91
+ POSTGRES_PORT = 5432
92
+ POSTGRES_DATABASE = "dbname"
93
+ POSTGRES_USER = "user"
94
+ POSTGRES_PASSWORD = "password"
95
+ ```
96
+
97
+ If `DATABASE_URL` is set, it takes priority and the individual connection settings are parsed from it.
98
+
99
+ To explicitly disable the database (e.g. during Docker builds where no database is available), set `DATABASE_URL=none`.
100
+
101
+ **PostgreSQL is the only supported database.** You need to install a PostgreSQL driver separately — [psycopg](https://www.psycopg.org/) is recommended:
102
+
103
+ ```bash
104
+ uv add psycopg[binary] # Pre-built wheels, easiest for local development
105
+ # or
106
+ uv add psycopg[c] # Compiled against your system's libpq, recommended for production
107
+ ```
108
+
109
+ ## Querying
110
+
111
+ Models come with a powerful query API through their [`QuerySet`](./query.py#QuerySet) interface:
112
+
113
+ ```python
114
+ # Get all users
115
+ all_users = User.query.all()
116
+
117
+ # Filter users
118
+ admin_users = User.query.filter(is_admin=True)
119
+ recent_users = User.query.filter(created_at__gte=datetime.now() - timedelta(days=7))
120
+
121
+ # Get a single user
122
+ user = User.query.get(email="test@example.com")
123
+
124
+ # Complex queries with Q objects
125
+ from plain.postgres import Q
126
+ users = User.query.filter(
127
+ Q(is_admin=True) | Q(email__endswith="@example.com")
128
+ )
129
+
130
+ # Ordering
131
+ users = User.query.order_by("-created_at")
132
+
133
+ # Limiting results
134
+ first_10_users = User.query.all()[:10]
135
+ ```
136
+
137
+ For more advanced querying options, see the [`QuerySet`](./query.py#QuerySet) class.
138
+
139
+ ### Custom QuerySets
140
+
141
+ You can customize [`QuerySet`](./query.py#QuerySet) classes to provide specialized query methods. Define a custom QuerySet and assign it to your model's `query` attribute:
142
+
143
+ ```python
144
+ from typing import Self
145
+ from plain.postgres import types
146
+
147
+ class PublishedQuerySet(postgres.QuerySet["Article"]):
148
+ def published_only(self) -> Self:
149
+ return self.filter(status="published")
150
+
151
+ def draft_only(self) -> Self:
152
+ return self.filter(status="draft")
153
+
154
+ @postgres.register_model
155
+ class Article(postgres.Model):
156
+ title: str = types.CharField(max_length=200)
157
+ status: str = types.CharField(max_length=20)
158
+
159
+ query = PublishedQuerySet()
160
+
161
+ # Usage - all methods available on Article.query
162
+ all_articles = Article.query.all()
163
+ published_articles = Article.query.published_only()
164
+ draft_articles = Article.query.draft_only()
165
+
166
+ # Chaining works naturally
167
+ recent_published = Article.query.published_only().order_by("-created_at")[:10]
168
+ ```
169
+
170
+ For internal code that needs to create QuerySet instances programmatically, use `from_model()`:
171
+
172
+ ```python
173
+ special_qs = SpecialQuerySet.from_model(Article)
174
+ ```
175
+
176
+ ### Typing QuerySets
177
+
178
+ For better type checking of query results, you can explicitly type the `query` attribute:
179
+
180
+ ```python
181
+ from __future__ import annotations
182
+
183
+ from plain import postgres
184
+ from plain.postgres import types
185
+
186
+ @postgres.register_model
187
+ class User(postgres.Model):
188
+ email: str = types.EmailField()
189
+ is_admin: bool = types.BooleanField(default=False)
190
+
191
+ query: postgres.QuerySet[User] = postgres.QuerySet()
192
+ ```
193
+
194
+ With this annotation, type checkers will know that `User.query.get()` returns a `User` instance and `User.query.filter()` returns `QuerySet[User]`. This is optional but improves IDE autocomplete and type checking.
195
+
196
+ ### Raw SQL
197
+
198
+ For complex queries that can't be expressed with the ORM, you can use raw SQL.
199
+
200
+ Use `Model.query.raw()` to execute raw SQL and get model instances back:
201
+
202
+ ```python
203
+ users = User.query.raw("""
204
+ SELECT * FROM users
205
+ WHERE created_at > %s
206
+ ORDER BY created_at DESC
207
+ """, [some_date])
208
+
209
+ for user in users:
210
+ print(user.email) # Full model instance with all fields
211
+ ```
212
+
213
+ Raw querysets support `prefetch_related()` for loading related objects:
214
+
215
+ ```python
216
+ users = User.query.raw("SELECT * FROM users WHERE is_admin = %s", [True])
217
+ users = users.prefetch_related("posts")
218
+ ```
219
+
220
+ For queries that don't map to a model, use the database cursor directly:
221
+
222
+ ```python
223
+ from plain.postgres import get_connection
224
+
225
+ with get_connection().cursor() as cursor:
226
+ cursor.execute("SELECT COUNT(*) FROM users WHERE is_admin = %s", [True])
227
+ count = cursor.fetchone()[0]
228
+ ```
229
+
230
+ For SQL set operations (UNION, INTERSECT, EXCEPT), use raw SQL. For simple cases, use Q objects instead:
231
+
232
+ ```python
233
+ from plain.postgres import Q
234
+
235
+ # Equivalent to UNION (on same model)
236
+ users = User.query.filter(Q(is_admin=True) | Q(is_staff=True))
237
+ ```
238
+
239
+ ### Avoiding N+1 queries
240
+
241
+ #### Use `select_related` for ForeignKey access in loops
242
+
243
+ Accessing a FK in a loop without `select_related()` fires one query per row.
244
+
245
+ ```python
246
+ # Bad — N+1 queries
247
+ for post in Post.query.all():
248
+ print(post.author.name)
249
+
250
+ # Good — single JOIN
251
+ for post in Post.query.select_related("author").all():
252
+ print(post.author.name)
253
+ ```
254
+
255
+ #### Use `prefetch_related` for reverse/M2N access in loops
256
+
257
+ Reverse ForeignKey and ManyToMany relations need a separate prefetch query.
258
+
259
+ ```python
260
+ # Bad — N+1 queries
261
+ for author in Author.query.all():
262
+ print(author.posts.count())
263
+
264
+ # Good — one extra query
265
+ for author in Author.query.prefetch_related("posts").all():
266
+ print(author.posts.count())
267
+ ```
268
+
269
+ #### Annotate instead of per-row aggregations
270
+
271
+ Use database-level aggregation instead of calling `.count()` or similar per row.
272
+
273
+ ```python
274
+ # Bad — N+1 queries
275
+ for category in Category.query.all():
276
+ print(category.products.count())
277
+
278
+ # Good — single query with annotation
279
+ from plain.postgres.aggregates import Count
280
+ for category in Category.query.annotate(num_products=Count("products")).all():
281
+ print(category.num_products)
282
+ ```
283
+
284
+ #### Fetch all data in the view
285
+
286
+ Templates should only render data, never trigger queries. Prepare everything in the view.
287
+
288
+ ```python
289
+ # Bad — template triggers lazy queries
290
+ def get_template_context(self):
291
+ return {"posts": Post.query.all()} # related lookups happen in template
292
+
293
+ # Good — eagerly load everything
294
+ def get_template_context(self):
295
+ return {"posts": Post.query.select_related("author").prefetch_related("tags").all()}
296
+ ```
297
+
298
+ ### Query efficiency
299
+
300
+ #### Use `.values_list()` when you only need specific columns
301
+
302
+ ```python
303
+ # Bad — loads entire model objects
304
+ emails = [u.email for u in User.query.all()]
305
+
306
+ # Good — single column, flat list
307
+ emails = list(User.query.values_list("email", flat=True))
308
+ ```
309
+
310
+ #### Use `.exists()` instead of `.count() > 0`
311
+
312
+ `.exists()` stops at the first match; `.count()` scans all matching rows.
313
+
314
+ ```python
315
+ # Bad
316
+ if User.query.filter(is_active=True).count() > 0: ...
317
+
318
+ # Good
319
+ if User.query.filter(is_active=True).exists(): ...
320
+ ```
321
+
322
+ #### Use `.count()` instead of `len(queryset)`
323
+
324
+ `len()` loads all objects into memory just to count them.
325
+
326
+ ```python
327
+ # Bad
328
+ total = len(User.query.all())
329
+
330
+ # Good
331
+ total = User.query.count()
332
+ ```
333
+
334
+ #### Use `bulk_create` / `bulk_update` for batch operations
335
+
336
+ Avoid calling `.save()` in a loop — each call is a separate query.
337
+
338
+ ```python
339
+ # Bad — N INSERT statements
340
+ for name in names:
341
+ Tag(name=name).save()
342
+
343
+ # Good — single INSERT
344
+ Tag.query.bulk_create([Tag(name=name) for name in names])
345
+ ```
346
+
347
+ #### Use queryset `.update()` / `.delete()` for mass operations
348
+
349
+ ```python
350
+ # Bad — N UPDATE statements
351
+ for user in User.query.filter(is_active=False):
352
+ user.is_archived = True
353
+ user.save()
354
+
355
+ # Good — single UPDATE statement
356
+ User.query.filter(is_active=False).update(is_archived=True)
357
+ ```
358
+
359
+ #### Use `.only()` / `.defer()` for heavy columns
360
+
361
+ Skip large text or JSON fields when you don't need them.
362
+
363
+ ```python
364
+ # Bad — loads large body text for a listing page
365
+ posts = Post.query.all()
366
+
367
+ # Good — defers heavy column
368
+ posts = Post.query.defer("body").all()
369
+ ```
370
+
371
+ #### Use `.iterator()` for large result sets
372
+
373
+ Process rows in chunks instead of loading everything into memory.
374
+
375
+ ```python
376
+ # Bad — entire table in memory
377
+ for row in HugeTable.query.all():
378
+ process(row)
379
+
380
+ # Good — chunked iteration
381
+ for row in HugeTable.query.iterator(chunk_size=2000):
382
+ process(row)
383
+ ```
384
+
385
+ ## Migrations
386
+
387
+ Migrations track changes to your models and update the database schema accordingly. They are Python files stored in your app's `migrations/` directory.
388
+
389
+ ### Creating migrations
390
+
391
+ ```bash
392
+ plain makemigrations
393
+ ```
394
+
395
+ Key flags:
396
+
397
+ - `--dry-run` — Show what migrations would be created (with operations and SQL) without writing files
398
+ - `--check` — Exit non-zero if migrations are needed (for CI)
399
+ - `--empty <package>` — Create an empty migration for custom data migrations
400
+ - `--name <name>` — Set the migration filename
401
+ - `-v 3` — Show full migration file contents
402
+
403
+ Only write migrations by hand if they are custom data migrations.
404
+
405
+ ### Running migrations
406
+
407
+ ```bash
408
+ plain migrate --backup
409
+ ```
410
+
411
+ Key flags:
412
+
413
+ - `--backup` / `--no-backup` — Create a database backup before applying (default: on in DEBUG)
414
+ - `--plan` — Show what migrations would run without applying them
415
+ - `--check` — Exit non-zero if unapplied migrations exist (for CI)
416
+ - `--fake` — Mark migrations as applied without running them
417
+
418
+ ### Viewing migration status
419
+
420
+ ```bash
421
+ plain migrations list
422
+ ```
423
+
424
+ `migrate` has no `--list` or `--status` flag. Use `plain migrations list`.
425
+
426
+ - `--format plan` — Show in dependency order instead of grouped by package
427
+
428
+ ### Development workflow
429
+
430
+ During development, iterating on models often produces multiple small migrations (0002, 0003, 0004...). Clean these up before committing.
431
+
432
+ **Consolidating uncommitted migrations (delete-and-recreate):**
433
+
434
+ Use this when migrations exist only in your local dev environment and haven't been committed or deployed.
435
+
436
+ 1. Delete the intermediate migration files (keep the initial 0001 and any previously committed migrations)
437
+ 2. `plain migrations prune --yes` — removes stale DB records for the deleted files
438
+ 3. `plain makemigrations` — creates a single fresh migration with all the changes
439
+ 4. `plain migrate --fake` — marks the new migration as applied (the schema is already correct from the old migrations)
440
+
441
+ **Consolidating committed migrations (squash):**
442
+
443
+ Use this when migrations have already been committed or deployed to other environments.
444
+
445
+ `plain migrations squash <package> <migration>` creates a replacement migration with a `replaces` list. Keep the original files until all environments have migrated past the squash point, then delete them and run `migrations prune`.
446
+
447
+ **Which method to use:**
448
+
449
+ | Scenario | Method |
450
+ | ----------------------------------------- | ------------------------------------------------------- |
451
+ | Migrations are local only (not committed) | Delete-and-recreate |
452
+ | Migrations are committed but not deployed | Delete-and-recreate (if all developers reset) or squash |
453
+ | Migrations are deployed to production | Squash |
454
+
455
+ ### Other migration commands
456
+
457
+ - `plain migrations squash <package> <migration>` — Squash migrations into one
458
+ - `plain migrations prune` — Remove stale migration records
459
+
460
+ ## Fields
461
+
462
+ You can use many field types for different data:
463
+
464
+ ```python
465
+ from decimal import Decimal
466
+ from datetime import datetime
467
+
468
+ from plain import postgres
469
+ from plain.postgres import types
470
+
471
+ class Product(postgres.Model):
472
+ # Text fields
473
+ name: str = types.CharField(max_length=200)
474
+ description: str = types.TextField()
475
+
476
+ # Numeric fields
477
+ price: Decimal = types.DecimalField(max_digits=10, decimal_places=2)
478
+ quantity: int = types.IntegerField(default=0)
479
+
480
+ # Boolean fields
481
+ is_active: bool = types.BooleanField(default=True)
482
+
483
+ # Date and time fields
484
+ created_at: datetime = types.DateTimeField(auto_now_add=True)
485
+ updated_at: datetime = types.DateTimeField(auto_now=True)
486
+ ```
487
+
488
+ **Text fields:**
489
+
490
+ - [`CharField`](./fields/__init__.py#CharField) - String with max length
491
+ - [`TextField`](./fields/__init__.py#TextField) - Unlimited text
492
+ - [`EmailField`](./fields/__init__.py#EmailField) - Email address (validated)
493
+ - [`URLField`](./fields/__init__.py#URLField) - URL (validated)
494
+
495
+ **Numeric fields:**
496
+
497
+ - [`IntegerField`](./fields/__init__.py#IntegerField) - Integer
498
+ - [`BigIntegerField`](./fields/__init__.py#BigIntegerField) - Big (8 byte) integer
499
+ - [`SmallIntegerField`](./fields/__init__.py#SmallIntegerField) - Small integer
500
+ - [`PositiveIntegerField`](./fields/__init__.py#PositiveIntegerField) - Positive integer
501
+ - [`PositiveBigIntegerField`](./fields/__init__.py#PositiveBigIntegerField) - Positive big integer
502
+ - [`PositiveSmallIntegerField`](./fields/__init__.py#PositiveSmallIntegerField) - Positive small integer
503
+ - [`FloatField`](./fields/__init__.py#FloatField) - Floating point number
504
+ - [`DecimalField`](./fields/__init__.py#DecimalField) - Fixed precision decimal
505
+
506
+ **Date and time fields:**
507
+
508
+ - [`DateField`](./fields/__init__.py#DateField) - Date (without time)
509
+ - [`DateTimeField`](./fields/__init__.py#DateTimeField) - Date with time
510
+ - [`TimeField`](./fields/__init__.py#TimeField) - Time (without date)
511
+ - [`DurationField`](./fields/__init__.py#DurationField) - Time duration (timedelta)
512
+ - [`TimeZoneField`](./fields/timezones.py#TimeZoneField) - Timezone (stored as string, accessed as ZoneInfo)
513
+
514
+ **Other fields:**
515
+
516
+ - [`BooleanField`](./fields/__init__.py#BooleanField) - True/False
517
+ - [`UUIDField`](./fields/__init__.py#UUIDField) - UUID
518
+ - [`BinaryField`](./fields/__init__.py#BinaryField) - Raw binary data
519
+ - [`JSONField`](./fields/json.py#JSONField) - JSON data
520
+ - [`GenericIPAddressField`](./fields/__init__.py#GenericIPAddressField) - IPv4 or IPv6 address
521
+
522
+ **Encrypted fields:**
523
+
524
+ - [`EncryptedTextField`](./fields/encrypted.py#EncryptedTextField) - Text encrypted at rest
525
+ - [`EncryptedJSONField`](./fields/encrypted.py#EncryptedJSONField) - JSON encrypted at rest
526
+
527
+ See [Encrypted fields](#encrypted-fields) for details.
528
+
529
+ For relationship fields, see [Relationships](#relationships).
530
+
531
+ For nullable fields, use `| None` in the annotation:
532
+
533
+ ```python
534
+ published_at: datetime | None = types.DateTimeField(allow_null=True, required=False)
535
+ ```
536
+
537
+ ### Sharing fields across models
538
+
539
+ To share common fields across multiple models, use Python classes as mixins. The final, registered model must inherit directly from `postgres.Model` and the mixins should not.
540
+
541
+ ```python
542
+ from datetime import datetime
543
+
544
+ from plain import postgres
545
+ from plain.postgres import types
546
+
547
+
548
+ # Regular Python class for shared fields
549
+ class TimestampedMixin:
550
+ created_at: datetime = types.DateTimeField(auto_now_add=True)
551
+ updated_at: datetime = types.DateTimeField(auto_now=True)
552
+
553
+
554
+ # Models inherit from the mixin AND postgres.Model
555
+ @postgres.register_model
556
+ class User(TimestampedMixin, postgres.Model):
557
+ email: str = types.EmailField()
558
+ password = PasswordField()
559
+ is_admin: bool = types.BooleanField(default=False)
560
+
561
+
562
+ @postgres.register_model
563
+ class Note(TimestampedMixin, postgres.Model):
564
+ content: str = types.TextField(max_length=1024)
565
+ liked: bool = types.BooleanField(default=False)
566
+ ```
567
+
568
+ ### Encrypted fields
569
+
570
+ Encrypted fields transparently encrypt values before writing to the database and decrypt on read. Use them for third-party credentials, API keys, OAuth tokens, and other secrets your application needs back in plaintext.
571
+
572
+ This is **not** for passwords or tokens you issue — those should be hashed (one-way). This is for secrets you receive from others and need to use later.
573
+
574
+ ```python
575
+ from plain import postgres
576
+ from plain.postgres import types
577
+
578
+ @postgres.register_model
579
+ class Integration(postgres.Model):
580
+ name: str = types.CharField(max_length=100)
581
+ api_key: str = types.EncryptedTextField(max_length=200)
582
+ credentials: dict = types.EncryptedJSONField(required=False, allow_null=True)
583
+ ```
584
+
585
+ Values are encrypted using Fernet (AES-128-CBC + HMAC-SHA256) with a key derived from `SECRET_KEY`. The `cryptography` package is required — install it with `pip install cryptography`.
586
+
587
+ **Available fields:**
588
+
589
+ - `EncryptedTextField` — encrypts text, stored as `text` in the database regardless of `max_length` (ciphertext is longer than plaintext). `max_length` is enforced on the plaintext value during validation.
590
+ - `EncryptedJSONField` — serializes to JSON, encrypts, and stores as `text`. Supports custom `encoder` and `decoder` parameters (same as `JSONField`).
591
+
592
+ **Limitations:**
593
+
594
+ - **No lookups** — encrypted values are non-deterministic (same plaintext produces different ciphertext each time), so filtering on encrypted fields doesn't work. Only `isnull` lookups are supported.
595
+ - **No indexes or constraints** — encrypted fields cannot be used in indexes or unique constraints. Preflight checks will catch this.
596
+
597
+ **Key rotation:**
598
+
599
+ Encryption uses `SECRET_KEY`. When rotating keys, add the old key to `SECRET_KEY_FALLBACKS` — the field will decrypt with any fallback key and re-encrypt with the current key on save.
600
+
601
+ **Gradual migration:**
602
+
603
+ If you add encryption to an existing plaintext column, old unencrypted values are returned as-is on read (the field detects whether a value is encrypted by its `$fernet$` prefix). They'll be encrypted on the next save.
604
+
605
+ ## Relationships
606
+
607
+ Use [`ForeignKeyField`](./fields/related.py#ForeignKeyField) for many-to-one and [`ManyToManyField`](./fields/related.py#ManyToManyField) for many-to-many:
608
+
609
+ ```python
610
+ from plain import postgres
611
+ from plain.postgres import types
612
+
613
+ @postgres.register_model
614
+ class Book(postgres.Model):
615
+ title: str = types.CharField(max_length=200)
616
+ author: Author = types.ForeignKeyField("Author", on_delete=postgres.CASCADE)
617
+ tags = types.ManyToManyField("Tag")
618
+ ```
619
+
620
+ ### Reverse relationships
621
+
622
+ When you define a `ForeignKey` or `ManyToManyField`, Plain automatically creates a reverse accessor on the related model (like `author.book_set`). You can explicitly declare these reverse relationships using [`ReverseForeignKey`](./fields/reverse_descriptors.py#ReverseForeignKey) and [`ReverseManyToMany`](./fields/reverse_descriptors.py#ReverseManyToMany):
623
+
624
+ ```python
625
+ from plain import postgres
626
+ from plain.postgres import types
627
+
628
+ @postgres.register_model
629
+ class Author(postgres.Model):
630
+ name: str = types.CharField(max_length=200)
631
+ # Explicit reverse accessor for all books by this author
632
+ books = types.ReverseForeignKey(to="Book", field="author")
633
+
634
+ @postgres.register_model
635
+ class Book(postgres.Model):
636
+ title: str = types.CharField(max_length=200)
637
+ author: Author = types.ForeignKeyField(Author, on_delete=postgres.CASCADE)
638
+
639
+ # Usage
640
+ author = Author.query.get(name="Jane Doe")
641
+ for book in author.books.all():
642
+ print(book.title)
643
+
644
+ # Add a new book
645
+ author.books.create(title="New Book")
646
+ ```
647
+
648
+ For many-to-many relationships:
649
+
650
+ ```python
651
+ @postgres.register_model
652
+ class Feature(postgres.Model):
653
+ name: str = types.CharField(max_length=100)
654
+ # Explicit reverse accessor for all cars with this feature
655
+ cars = types.ReverseManyToMany(to="Car", field="features")
656
+
657
+ @postgres.register_model
658
+ class Car(postgres.Model):
659
+ model: str = types.CharField(max_length=100)
660
+ features = types.ManyToManyField(Feature)
661
+
662
+ # Usage
663
+ feature = Feature.query.get(name="Sunroof")
664
+ for car in feature.cars.all():
665
+ print(car.model)
666
+ ```
667
+
668
+ **Why use explicit reverse relations?**
669
+
670
+ - **Self-documenting**: The reverse accessor is visible in the model definition
671
+ - **Better IDE support**: Autocomplete works for reverse accessors
672
+ - **Type safety**: When combined with type annotations, type checkers understand the relationship
673
+ - **Control**: You choose the accessor name instead of relying on automatic `_set` naming
674
+
675
+ Reverse relations are optional — if you don't declare them, the automatic `{model}_set` accessor still works.
676
+
677
+ To get type checking for custom QuerySet methods on reverse relations, specify the QuerySet type as a second parameter:
678
+
679
+ ```python
680
+ # Basic usage
681
+ books: types.ReverseForeignKey[Book] = types.ReverseForeignKey(to="Book", field="author")
682
+
683
+ # With custom QuerySet for proper method recognition
684
+ books: types.ReverseForeignKey[Book, BookQuerySet] = types.ReverseForeignKey(to="Book", field="author")
685
+
686
+ # Now type checkers recognize custom methods like .published()
687
+ author.books.query.published()
688
+ ```
689
+
690
+ ## Constraints
691
+
692
+ ### Validation
693
+
694
+ You can validate models before saving:
695
+
696
+ ```python
697
+ @postgres.register_model
698
+ class User(postgres.Model):
699
+ email: str = types.EmailField()
700
+ age: int = types.IntegerField()
701
+
702
+ model_options = postgres.Options(
703
+ constraints=[
704
+ postgres.UniqueConstraint(fields=["email"], name="unique_email"),
705
+ ],
706
+ )
707
+
708
+ def clean(self):
709
+ if self.age < 18:
710
+ raise ValidationError("User must be 18 or older")
711
+
712
+ def save(self, *args, **kwargs):
713
+ self.full_clean() # Runs validation
714
+ super().save(*args, **kwargs)
715
+ ```
716
+
717
+ Field-level validation happens automatically based on field types and constraints.
718
+
719
+ ### Indexes and constraints
720
+
721
+ You can optimize queries and ensure data integrity with indexes and constraints:
722
+
723
+ ```python
724
+ class User(postgres.Model):
725
+ email: str = types.EmailField()
726
+ username: str = types.CharField(max_length=150)
727
+ age: int = types.IntegerField()
728
+
729
+ model_options = postgres.Options(
730
+ indexes=[
731
+ postgres.Index(fields=["email"]),
732
+ postgres.Index(fields=["-created_at"], name="user_created_idx"),
733
+ ],
734
+ constraints=[
735
+ postgres.UniqueConstraint(fields=["email", "username"], name="unique_user"),
736
+ postgres.CheckConstraint(check=postgres.Q(age__gte=0), name="age_positive"),
737
+ ],
738
+ )
739
+ ```
740
+
741
+ ### Schema design
742
+
743
+ #### Index fields used in filters and ordering
744
+
745
+ Add indexes for columns that appear in `.filter()`, `.order_by()`, or `.exclude()`.
746
+
747
+ ```python
748
+ # Bad — full table scan on every filtered query
749
+ class Order(postgres.Model):
750
+ status: str = types.CharField(max_length=20)
751
+ created_at: datetime = types.DateTimeField()
752
+
753
+ # Good — indexed for common queries
754
+ class Order(postgres.Model):
755
+ status: str = types.CharField(max_length=20)
756
+ created_at: datetime = types.DateTimeField()
757
+
758
+ model_options = postgres.Options(
759
+ indexes=[postgres.Index(fields=["status", "-created_at"])],
760
+ )
761
+ ```
762
+
763
+ #### Use database constraints, not app-only validation
764
+
765
+ Enforce uniqueness and data integrity at the database level.
766
+
767
+ ```python
768
+ # Bad — only validated in Python
769
+ def save(self):
770
+ if MyModel.query.filter(email=self.email).exists():
771
+ raise ValueError("duplicate")
772
+
773
+ # Good — database-enforced
774
+ model_options = postgres.Options(
775
+ constraints=[postgres.UniqueConstraint(fields=["email"])],
776
+ )
777
+ ```
778
+
779
+ #### Choose `on_delete` deliberately
780
+
781
+ CASCADE for owned children, PROTECT for referenced data, SET_NULL for optional references.
782
+
783
+ ```python
784
+ # Bad — blindly using CASCADE everywhere
785
+ company: Company = types.ForeignKeyField("Company", on_delete=postgres.CASCADE) # deleting company deletes invoices!
786
+
787
+ # Good — protect referenced data
788
+ company: Company = types.ForeignKeyField("Company", on_delete=postgres.PROTECT)
789
+ ```
790
+
791
+ #### No `allow_null` on string fields
792
+
793
+ Use `default=""` instead of `allow_null=True` to avoid two representations of "empty."
794
+
795
+ ```python
796
+ # Bad — NULL and "" both mean "empty"
797
+ nickname: str = types.CharField(max_length=50, allow_null=True)
798
+
799
+ # Good — single empty representation
800
+ nickname: str = types.CharField(max_length=50, default="")
801
+ ```
802
+
803
+ ## Forms
804
+
805
+ Models integrate with [plain.forms](../../../plain-forms/plain/forms/README.md):
806
+
807
+ ```python
808
+ from plain import forms
809
+ from .models import User
810
+
811
+ class UserForm(forms.ModelForm):
812
+ class Meta:
813
+ model = User
814
+ fields = ["email", "is_admin"]
815
+
816
+ # Usage
817
+ form = UserForm(request=request)
818
+ if form.is_valid():
819
+ user = form.save()
820
+ ```
821
+
822
+ ## Architecture
823
+
824
+ ```mermaid
825
+ graph TB
826
+ subgraph "User API"
827
+ Model["Model"]
828
+ QS["QuerySet"]
829
+ Expr["Expressions<br/><small>F() Q() Value()</small>"]
830
+ end
831
+
832
+ subgraph "Query Layer"
833
+ Query["Query"]
834
+ Where["WhereNode"]
835
+ Join["Join"]
836
+ end
837
+
838
+ subgraph "Compilation"
839
+ Compiler["SQLCompiler"]
840
+ end
841
+
842
+ subgraph "Database"
843
+ Connection["DatabaseConnection"]
844
+ DB[(Database)]
845
+ end
846
+
847
+ Model -- ".query" --> QS
848
+ QS -- "owns" --> Query
849
+ Expr -- "used by" --> Query
850
+ Query -- "contains" --> Where
851
+ Query -- "contains" --> Join
852
+ Query -- "get_compiler()" --> Compiler
853
+ Compiler -- "execute_sql()" --> Connection
854
+ Connection -- "executes" --> DB
855
+ ```
856
+
857
+ **Query execution flow:**
858
+
859
+ 1. **Model.query** returns a [`QuerySet`](./query.py#QuerySet) bound to the model
860
+ 2. **QuerySet** methods like `.filter()` modify the internal [`Query`](./sql/query.py#Query) object
861
+ 3. When results are needed, **Query.get_compiler()** creates the appropriate [`SQLCompiler`](./sql/compiler.py#SQLCompiler)
862
+ 4. **SQLCompiler.as_sql()** renders the Query to SQL
863
+ 5. **SQLCompiler.execute_sql()** runs the SQL via [`DatabaseConnection`](./postgres/connection.py#DatabaseConnection) and returns results
864
+
865
+ **Key components:**
866
+
867
+ - [`Model`](./base.py#Model) - Defines fields, relationships, and provides the `query` attribute
868
+ - [`QuerySet`](./query.py#QuerySet) - Chainable API (`.filter()`, `.exclude()`, `.order_by()`) that builds a Query
869
+ - [`Query`](./sql/query.py#Query) - Internal representation of a query's logical structure (tables, joins, filters)
870
+ - [`SQLCompiler`](./sql/compiler.py#SQLCompiler) - Transforms a Query into executable SQL
871
+ - [`DatabaseConnection`](./postgres/connection.py#DatabaseConnection) - PostgreSQL connection and query execution
872
+
873
+ ## Settings
874
+
875
+ Connection settings are configured via `DATABASE_URL` or individual `POSTGRES_*` settings.
876
+
877
+ 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.
878
+
879
+ Set `DATABASE_URL=none` to explicitly disable the database (e.g. during Docker image builds).
880
+
881
+ | Setting | Type | Default | Env var |
882
+ | ----------------------------- | ------------- | ------- | ----------------------------------- |
883
+ | `POSTGRES_HOST` | `str` | — | `PLAIN_POSTGRES_HOST` |
884
+ | `POSTGRES_PORT` | `int \| None` | `None` | `PLAIN_POSTGRES_PORT` |
885
+ | `POSTGRES_DATABASE` | `str` | — | `PLAIN_POSTGRES_DATABASE` |
886
+ | `POSTGRES_USER` | `str` | — | `PLAIN_POSTGRES_USER` |
887
+ | `POSTGRES_PASSWORD` | `Secret[str]` | — | `PLAIN_POSTGRES_PASSWORD` |
888
+ | `POSTGRES_CONN_MAX_AGE` | `int` | `600` | `PLAIN_POSTGRES_CONN_MAX_AGE` |
889
+ | `POSTGRES_CONN_HEALTH_CHECKS` | `bool` | `True` | `PLAIN_POSTGRES_CONN_HEALTH_CHECKS` |
890
+ | `POSTGRES_OPTIONS` | `dict` | `{}` | — |
891
+ | `POSTGRES_TIME_ZONE` | `str \| None` | `None` | `PLAIN_POSTGRES_TIME_ZONE` |
892
+
893
+ See [`default_settings.py`](./default_settings.py) for more details.
894
+
895
+ ## FAQs
896
+
897
+ #### How do I add a field to an existing model?
898
+
899
+ 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.
900
+
901
+ #### What's the difference between `CharField` and `TextField`?
902
+
903
+ `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.
904
+
905
+ #### How do I create a unique constraint on multiple fields?
906
+
907
+ Use `UniqueConstraint` in your model's `model_options`:
908
+
909
+ ```python
910
+ model_options = postgres.Options(
911
+ constraints=[
912
+ postgres.UniqueConstraint(fields=["email", "organization"], name="unique_email_per_org"),
913
+ ],
914
+ )
915
+ ```
916
+
917
+ #### Can I use multiple databases?
918
+
919
+ Currently, Plain supports a single database connection per application. For applications requiring multiple databases, you can use raw SQL with separate connection management.
920
+
921
+ ## Installation
922
+
923
+ Install the `plain.postgres` package from [PyPI](https://pypi.org/project/plain.postgres/):
924
+
925
+ ```bash
926
+ uv add plain.postgres psycopg[binary]
927
+ ```
928
+
929
+ Then add to your `INSTALLED_PACKAGES`:
930
+
931
+ ```python
932
+ # app/settings.py
933
+ INSTALLED_PACKAGES = [
934
+ ...
935
+ "plain.postgres",
936
+ ]
937
+ ```