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