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.
- plain/postgres/CHANGELOG.md +1028 -0
- plain/postgres/README.md +925 -0
- plain/postgres/__init__.py +120 -0
- plain/postgres/agents/.claude/rules/plain-postgres.md +78 -0
- plain/postgres/aggregates.py +236 -0
- plain/postgres/backups/__init__.py +0 -0
- plain/postgres/backups/cli.py +148 -0
- plain/postgres/backups/clients.py +94 -0
- plain/postgres/backups/core.py +172 -0
- plain/postgres/base.py +1415 -0
- plain/postgres/cli/__init__.py +3 -0
- plain/postgres/cli/db.py +142 -0
- plain/postgres/cli/migrations.py +1085 -0
- plain/postgres/config.py +18 -0
- plain/postgres/connection.py +1331 -0
- plain/postgres/connections.py +77 -0
- plain/postgres/constants.py +13 -0
- plain/postgres/constraints.py +495 -0
- plain/postgres/database_url.py +94 -0
- plain/postgres/db.py +59 -0
- plain/postgres/default_settings.py +38 -0
- plain/postgres/deletion.py +475 -0
- plain/postgres/dialect.py +640 -0
- plain/postgres/entrypoints.py +4 -0
- plain/postgres/enums.py +103 -0
- plain/postgres/exceptions.py +217 -0
- plain/postgres/expressions.py +1912 -0
- plain/postgres/fields/__init__.py +2118 -0
- plain/postgres/fields/encrypted.py +354 -0
- plain/postgres/fields/json.py +413 -0
- plain/postgres/fields/mixins.py +30 -0
- plain/postgres/fields/related.py +1192 -0
- plain/postgres/fields/related_descriptors.py +290 -0
- plain/postgres/fields/related_lookups.py +223 -0
- plain/postgres/fields/related_managers.py +661 -0
- plain/postgres/fields/reverse_descriptors.py +229 -0
- plain/postgres/fields/reverse_related.py +328 -0
- plain/postgres/fields/timezones.py +143 -0
- plain/postgres/forms.py +773 -0
- plain/postgres/functions/__init__.py +189 -0
- plain/postgres/functions/comparison.py +127 -0
- plain/postgres/functions/datetime.py +454 -0
- plain/postgres/functions/math.py +140 -0
- plain/postgres/functions/mixins.py +59 -0
- plain/postgres/functions/text.py +282 -0
- plain/postgres/functions/window.py +125 -0
- plain/postgres/indexes.py +286 -0
- plain/postgres/lookups.py +758 -0
- plain/postgres/meta.py +584 -0
- plain/postgres/migrations/__init__.py +53 -0
- plain/postgres/migrations/autodetector.py +1379 -0
- plain/postgres/migrations/exceptions.py +54 -0
- plain/postgres/migrations/executor.py +188 -0
- plain/postgres/migrations/graph.py +364 -0
- plain/postgres/migrations/loader.py +377 -0
- plain/postgres/migrations/migration.py +180 -0
- plain/postgres/migrations/operations/__init__.py +34 -0
- plain/postgres/migrations/operations/base.py +139 -0
- plain/postgres/migrations/operations/fields.py +373 -0
- plain/postgres/migrations/operations/models.py +798 -0
- plain/postgres/migrations/operations/special.py +184 -0
- plain/postgres/migrations/optimizer.py +74 -0
- plain/postgres/migrations/questioner.py +340 -0
- plain/postgres/migrations/recorder.py +119 -0
- plain/postgres/migrations/serializer.py +378 -0
- plain/postgres/migrations/state.py +882 -0
- plain/postgres/migrations/utils.py +147 -0
- plain/postgres/migrations/writer.py +302 -0
- plain/postgres/options.py +207 -0
- plain/postgres/otel.py +231 -0
- plain/postgres/preflight.py +336 -0
- plain/postgres/query.py +2242 -0
- plain/postgres/query_utils.py +456 -0
- plain/postgres/registry.py +217 -0
- plain/postgres/schema.py +1885 -0
- plain/postgres/sql/__init__.py +40 -0
- plain/postgres/sql/compiler.py +1869 -0
- plain/postgres/sql/constants.py +22 -0
- plain/postgres/sql/datastructures.py +222 -0
- plain/postgres/sql/query.py +2947 -0
- plain/postgres/sql/where.py +374 -0
- plain/postgres/test/__init__.py +0 -0
- plain/postgres/test/pytest.py +117 -0
- plain/postgres/test/utils.py +18 -0
- plain/postgres/transaction.py +222 -0
- plain/postgres/types.py +92 -0
- plain/postgres/types.pyi +751 -0
- plain/postgres/utils.py +345 -0
- plain_postgres-0.84.0.dist-info/METADATA +937 -0
- plain_postgres-0.84.0.dist-info/RECORD +93 -0
- plain_postgres-0.84.0.dist-info/WHEEL +4 -0
- plain_postgres-0.84.0.dist-info/entry_points.txt +5 -0
- plain_postgres-0.84.0.dist-info/licenses/LICENSE +61 -0
plain/postgres/README.md
ADDED
|
@@ -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
|
+
```
|