plain.postgres 0.84.2__tar.gz → 0.85.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plain_postgres-0.85.0/CLAUDE.md +11 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/PKG-INFO +58 -1
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/CHANGELOG.md +14 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/README.md +57 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/__init__.py +1 -2
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/base.py +7 -6
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/cli/db.py +3 -2
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/connection.py +37 -38
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/connections.py +21 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/db.py +0 -22
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/deletion.py +6 -5
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/dialect.py +2 -2
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/exceptions.py +1 -105
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/expressions.py +3 -2
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/exceptions.py +2 -2
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/recorder.py +3 -2
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/query.py +3 -2
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/query_utils.py +3 -2
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/sql/query.py +4 -2
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/transaction.py +10 -8
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/utils.py +25 -40
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/pyproject.toml +1 -1
- plain_postgres-0.85.0/tests/test_read_only_transactions.py +116 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/.gitignore +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/LICENSE +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/README.md +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/aggregates.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/backups/__init__.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/backups/cli.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/backups/clients.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/backups/core.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/cli/__init__.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/cli/migrations.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/config.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/constants.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/constraints.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/database_url.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/default_settings.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/entrypoints.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/enums.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/fields/__init__.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/fields/encrypted.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/fields/json.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/fields/mixins.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/fields/related.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/fields/related_descriptors.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/fields/related_lookups.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/fields/related_managers.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/fields/reverse_related.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/fields/timezones.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/forms.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/functions/__init__.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/functions/comparison.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/functions/datetime.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/functions/math.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/functions/mixins.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/functions/text.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/functions/window.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/indexes.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/lookups.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/meta.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/__init__.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/autodetector.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/executor.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/graph.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/loader.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/migration.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/operations/__init__.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/operations/base.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/operations/fields.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/operations/models.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/operations/special.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/optimizer.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/questioner.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/serializer.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/state.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/utils.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/writer.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/options.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/otel.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/preflight.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/registry.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/schema.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/sql/__init__.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/sql/compiler.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/sql/constants.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/sql/datastructures.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/sql/where.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/test/__init__.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/test/pytest.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/test/utils.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/types.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/types.pyi +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/app/examples/migrations/0001_initial.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/app/examples/migrations/__init__.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/app/examples/models.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/app/settings.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/app/urls.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/test_connection_isolation.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/test_connection_lifecycle.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/test_database_url.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/test_delete_behaviors.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/test_encrypted_fields.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/test_exceptions.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/test_iterator.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/test_manager_assignment.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/test_models.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/test_related_descriptors.py +0 -0
- {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/test_related_manager_api.py +0 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Developing plain-postgres
|
|
2
|
+
|
|
3
|
+
## PostgreSQL
|
|
4
|
+
|
|
5
|
+
Minimum supported version is **PostgreSQL 16** (enforced by preflight check). Use PG 16+ features freely.
|
|
6
|
+
|
|
7
|
+
When implementing database features, fetch the official PostgreSQL docs to verify behavior — don't assume SQL semantics from memory. Check both the minimum version docs and the latest for newer features worth adopting: https://www.postgresql.org/docs/
|
|
8
|
+
|
|
9
|
+
## psycopg3
|
|
10
|
+
|
|
11
|
+
The database driver is psycopg3 (`import psycopg`). Prefer its native APIs over hand-rolled abstractions.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: plain.postgres
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.85.0
|
|
4
4
|
Summary: Model your data and store it in a database.
|
|
5
5
|
Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -382,6 +382,63 @@ for row in HugeTable.query.iterator(chunk_size=2000):
|
|
|
382
382
|
process(row)
|
|
383
383
|
```
|
|
384
384
|
|
|
385
|
+
## Transactions
|
|
386
|
+
|
|
387
|
+
By default, each query runs in its own implicit transaction and is committed immediately (autocommit mode). When you need multiple queries to succeed or fail together — like creating a user and their profile — wrap them in an explicit transaction.
|
|
388
|
+
|
|
389
|
+
### Atomic blocks
|
|
390
|
+
|
|
391
|
+
Wrap multiple queries in a transaction with `transaction.atomic()`:
|
|
392
|
+
|
|
393
|
+
```python
|
|
394
|
+
from plain.postgres import transaction
|
|
395
|
+
|
|
396
|
+
with transaction.atomic():
|
|
397
|
+
user = User(email="test@example.com")
|
|
398
|
+
user.save()
|
|
399
|
+
Profile(user=user).save()
|
|
400
|
+
# Both saves commit together, or both roll back on error
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
Nesting `atomic()` creates savepoints:
|
|
404
|
+
|
|
405
|
+
```python
|
|
406
|
+
with transaction.atomic():
|
|
407
|
+
user.save()
|
|
408
|
+
try:
|
|
409
|
+
with transaction.atomic():
|
|
410
|
+
risky_operation() # If this fails...
|
|
411
|
+
except SomeError:
|
|
412
|
+
pass # ...only the inner block rolls back
|
|
413
|
+
safe_operation() # This still runs in the outer transaction
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
### Read-only connections
|
|
417
|
+
|
|
418
|
+
Enforce read-only mode on the current database connection using `read_only()`. Any write (INSERT, UPDATE, DELETE, DDL) raises `psycopg.errors.ReadOnlySqlTransaction`:
|
|
419
|
+
|
|
420
|
+
```python
|
|
421
|
+
from plain.postgres.connections import read_only
|
|
422
|
+
|
|
423
|
+
with read_only():
|
|
424
|
+
users = User.query.all() # reads work
|
|
425
|
+
User.query.create(name="x") # raises psycopg.errors.ReadOnlySqlTransaction
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
This works with both autocommit queries and explicit `atomic()` blocks.
|
|
429
|
+
|
|
430
|
+
For sticky read-only mode (e.g., a shell session), use `set_read_only()` on the connection directly:
|
|
431
|
+
|
|
432
|
+
```python
|
|
433
|
+
from plain.postgres.db import get_connection
|
|
434
|
+
|
|
435
|
+
conn = get_connection()
|
|
436
|
+
conn.set_read_only(True) # all subsequent queries are read-only
|
|
437
|
+
conn.set_read_only(False) # back to normal
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
Read-only mode must be set outside a transaction — calling it inside `atomic()` raises `TransactionManagementError`.
|
|
441
|
+
|
|
385
442
|
## Migrations
|
|
386
443
|
|
|
387
444
|
Migrations track changes to your models and update the database schema accordingly. They are Python files stored in your app's `migrations/` directory.
|
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# plain-postgres changelog
|
|
2
2
|
|
|
3
|
+
## [0.85.0](https://github.com/dropseed/plain/releases/plain-postgres@0.85.0) (2026-03-22)
|
|
4
|
+
|
|
5
|
+
### What's changed
|
|
6
|
+
|
|
7
|
+
- Added read-only database connection support via `read_only()` context manager and `connection.set_read_only()` — enforces `SET default_transaction_read_only = ON` so any write attempt raises a database error ([69d23b04fde9](https://github.com/dropseed/plain/commit/69d23b04fde9))
|
|
8
|
+
- Removed PEP-249 exception mirror — `IntegrityError`, `OperationalError`, `ProgrammingError`, etc. are no longer re-exported from `plain.postgres`. Use `psycopg` exceptions directly (e.g. `psycopg.IntegrityError`) ([d4b170e60a2c](https://github.com/dropseed/plain/commit/d4b170e60a2c))
|
|
9
|
+
- Removed `DatabaseErrorWrapper` context manager — psycopg's native connection state handling replaces it ([015b04ce38e9](https://github.com/dropseed/plain/commit/015b04ce38e9))
|
|
10
|
+
- Added transaction and read-only connection documentation to README
|
|
11
|
+
|
|
12
|
+
### Upgrade instructions
|
|
13
|
+
|
|
14
|
+
- Replace any `from plain.postgres import IntegrityError` (or `OperationalError`, `ProgrammingError`, etc.) with `import psycopg` and use `psycopg.IntegrityError` directly.
|
|
15
|
+
- Replace any usage of `plain.postgres.db.DatabaseErrorWrapper` with standard `try/except` on psycopg exceptions.
|
|
16
|
+
|
|
3
17
|
## [0.84.2](https://github.com/dropseed/plain/releases/plain-postgres@0.84.2) (2026-03-20)
|
|
4
18
|
|
|
5
19
|
### What's changed
|
|
@@ -370,6 +370,63 @@ for row in HugeTable.query.iterator(chunk_size=2000):
|
|
|
370
370
|
process(row)
|
|
371
371
|
```
|
|
372
372
|
|
|
373
|
+
## Transactions
|
|
374
|
+
|
|
375
|
+
By default, each query runs in its own implicit transaction and is committed immediately (autocommit mode). When you need multiple queries to succeed or fail together — like creating a user and their profile — wrap them in an explicit transaction.
|
|
376
|
+
|
|
377
|
+
### Atomic blocks
|
|
378
|
+
|
|
379
|
+
Wrap multiple queries in a transaction with `transaction.atomic()`:
|
|
380
|
+
|
|
381
|
+
```python
|
|
382
|
+
from plain.postgres import transaction
|
|
383
|
+
|
|
384
|
+
with transaction.atomic():
|
|
385
|
+
user = User(email="test@example.com")
|
|
386
|
+
user.save()
|
|
387
|
+
Profile(user=user).save()
|
|
388
|
+
# Both saves commit together, or both roll back on error
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
Nesting `atomic()` creates savepoints:
|
|
392
|
+
|
|
393
|
+
```python
|
|
394
|
+
with transaction.atomic():
|
|
395
|
+
user.save()
|
|
396
|
+
try:
|
|
397
|
+
with transaction.atomic():
|
|
398
|
+
risky_operation() # If this fails...
|
|
399
|
+
except SomeError:
|
|
400
|
+
pass # ...only the inner block rolls back
|
|
401
|
+
safe_operation() # This still runs in the outer transaction
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
### Read-only connections
|
|
405
|
+
|
|
406
|
+
Enforce read-only mode on the current database connection using `read_only()`. Any write (INSERT, UPDATE, DELETE, DDL) raises `psycopg.errors.ReadOnlySqlTransaction`:
|
|
407
|
+
|
|
408
|
+
```python
|
|
409
|
+
from plain.postgres.connections import read_only
|
|
410
|
+
|
|
411
|
+
with read_only():
|
|
412
|
+
users = User.query.all() # reads work
|
|
413
|
+
User.query.create(name="x") # raises psycopg.errors.ReadOnlySqlTransaction
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
This works with both autocommit queries and explicit `atomic()` blocks.
|
|
417
|
+
|
|
418
|
+
For sticky read-only mode (e.g., a shell session), use `set_read_only()` on the connection directly:
|
|
419
|
+
|
|
420
|
+
```python
|
|
421
|
+
from plain.postgres.db import get_connection
|
|
422
|
+
|
|
423
|
+
conn = get_connection()
|
|
424
|
+
conn.set_read_only(True) # all subsequent queries are read-only
|
|
425
|
+
conn.set_read_only(False) # back to normal
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
Read-only mode must be set outside a transaction — calling it inside `atomic()` raises `TransactionManagementError`.
|
|
429
|
+
|
|
373
430
|
## Migrations
|
|
374
431
|
|
|
375
432
|
Migrations track changes to your models and update the database schema accordingly. They are Python files stored in your app's `migrations/` directory.
|
|
@@ -6,7 +6,7 @@ from . import (
|
|
|
6
6
|
# Imports that would create circular imports if sorted
|
|
7
7
|
from .base import Model
|
|
8
8
|
from .constraints import CheckConstraint, UniqueConstraint
|
|
9
|
-
from .db import
|
|
9
|
+
from .db import get_connection
|
|
10
10
|
from .deletion import CASCADE, DO_NOTHING, PROTECT, RESTRICT, SET, SET_DEFAULT, SET_NULL
|
|
11
11
|
from .enums import IntegerChoices, TextChoices
|
|
12
12
|
from .fields import (
|
|
@@ -111,7 +111,6 @@ __all__ = [
|
|
|
111
111
|
"ReverseManyToMany",
|
|
112
112
|
# From db
|
|
113
113
|
"get_connection",
|
|
114
|
-
"IntegrityError",
|
|
115
114
|
# From registry
|
|
116
115
|
"register_model",
|
|
117
116
|
"models_registry",
|
|
@@ -10,15 +10,14 @@ if TYPE_CHECKING:
|
|
|
10
10
|
from plain.postgres.meta import Meta
|
|
11
11
|
from plain.postgres.options import Options
|
|
12
12
|
|
|
13
|
+
import psycopg
|
|
14
|
+
|
|
13
15
|
import plain.runtime
|
|
14
16
|
from plain.exceptions import NON_FIELD_ERRORS, ValidationError
|
|
15
17
|
from plain.postgres import models_registry, transaction, types
|
|
16
18
|
from plain.postgres.constants import LOOKUP_SEP
|
|
17
19
|
from plain.postgres.constraints import CheckConstraint, UniqueConstraint
|
|
18
|
-
from plain.postgres.db import
|
|
19
|
-
PLAIN_VERSION_PICKLE_KEY,
|
|
20
|
-
DatabaseError,
|
|
21
|
-
)
|
|
20
|
+
from plain.postgres.db import PLAIN_VERSION_PICKLE_KEY
|
|
22
21
|
from plain.postgres.deletion import Collector
|
|
23
22
|
from plain.postgres.dialect import MAX_NAME_LENGTH
|
|
24
23
|
from plain.postgres.exceptions import (
|
|
@@ -496,9 +495,11 @@ class Model(metaclass=ModelBase):
|
|
|
496
495
|
base_qs, id_val, values, update_fields, forced_update
|
|
497
496
|
)
|
|
498
497
|
if force_update and not updated:
|
|
499
|
-
raise DatabaseError("Forced update did not affect any rows.")
|
|
498
|
+
raise psycopg.DatabaseError("Forced update did not affect any rows.")
|
|
500
499
|
if update_fields and not updated:
|
|
501
|
-
raise DatabaseError(
|
|
500
|
+
raise psycopg.DatabaseError(
|
|
501
|
+
"Save with update_fields did not affect any rows."
|
|
502
|
+
)
|
|
502
503
|
if not updated:
|
|
503
504
|
fields = meta.local_concrete_fields
|
|
504
505
|
if not id_set:
|
|
@@ -6,11 +6,12 @@ import time
|
|
|
6
6
|
from collections import defaultdict
|
|
7
7
|
|
|
8
8
|
import click
|
|
9
|
+
import psycopg
|
|
9
10
|
|
|
10
11
|
from plain.cli import register_cli
|
|
11
12
|
|
|
12
13
|
from ..backups.cli import cli as backups_cli
|
|
13
|
-
from ..db import
|
|
14
|
+
from ..db import get_connection
|
|
14
15
|
from ..dialect import quote_name
|
|
15
16
|
from ..migrations.recorder import MIGRATION_TABLE_NAME
|
|
16
17
|
|
|
@@ -126,7 +127,7 @@ def wait() -> None:
|
|
|
126
127
|
|
|
127
128
|
try:
|
|
128
129
|
get_connection().ensure_connection()
|
|
129
|
-
except OperationalError:
|
|
130
|
+
except psycopg.OperationalError:
|
|
130
131
|
waiting_for = True
|
|
131
132
|
|
|
132
133
|
if waiting_for:
|
|
@@ -28,9 +28,6 @@ from psycopg.types.string import TextLoader
|
|
|
28
28
|
from plain.exceptions import ImproperlyConfigured
|
|
29
29
|
from plain.logs import get_framework_logger
|
|
30
30
|
from plain.postgres import utils
|
|
31
|
-
from plain.postgres.db import (
|
|
32
|
-
DatabaseErrorWrapper,
|
|
33
|
-
)
|
|
34
31
|
from plain.postgres.dialect import MAX_NAME_LENGTH, quote_name
|
|
35
32
|
from plain.postgres.indexes import Index
|
|
36
33
|
from plain.postgres.schema import DatabaseSchemaEditor
|
|
@@ -203,7 +200,6 @@ class DatabaseConnection:
|
|
|
203
200
|
# Connection termination related attributes.
|
|
204
201
|
self.close_at: float | None = None
|
|
205
202
|
self.closed_in_transaction: bool = False
|
|
206
|
-
self.errors_occurred: bool = False
|
|
207
203
|
self.health_check_enabled: bool = False
|
|
208
204
|
self.health_check_done: bool = False
|
|
209
205
|
|
|
@@ -383,8 +379,30 @@ class DatabaseConnection:
|
|
|
383
379
|
def _set_autocommit(self, autocommit: bool) -> None:
|
|
384
380
|
"""Backend-specific implementation to enable or disable autocommit."""
|
|
385
381
|
assert self.connection is not None
|
|
386
|
-
|
|
387
|
-
|
|
382
|
+
self.connection.autocommit = autocommit
|
|
383
|
+
|
|
384
|
+
def set_read_only(self, read_only: bool) -> None:
|
|
385
|
+
"""Set read-only mode on this connection.
|
|
386
|
+
|
|
387
|
+
When enabled, all subsequent transactions will be read-only —
|
|
388
|
+
any INSERT/UPDATE/DELETE/DDL will raise a database error.
|
|
389
|
+
This applies to both explicit transactions and autocommit queries.
|
|
390
|
+
Persists until changed or the connection is closed.
|
|
391
|
+
|
|
392
|
+
Must be called outside a transaction — the setting only takes
|
|
393
|
+
effect on the next transaction that starts.
|
|
394
|
+
"""
|
|
395
|
+
if self.in_atomic_block:
|
|
396
|
+
raise TransactionManagementError(
|
|
397
|
+
"set_read_only() cannot be called inside a transaction. "
|
|
398
|
+
"Call it before entering an atomic block."
|
|
399
|
+
)
|
|
400
|
+
self.ensure_connection()
|
|
401
|
+
assert self.connection is not None
|
|
402
|
+
if read_only:
|
|
403
|
+
self.connection.execute("SET default_transaction_read_only = on")
|
|
404
|
+
else:
|
|
405
|
+
self.connection.execute("SET default_transaction_read_only = off")
|
|
388
406
|
|
|
389
407
|
def check_constraints(self, table_names: list[str] | None = None) -> None:
|
|
390
408
|
"""
|
|
@@ -449,7 +467,6 @@ class DatabaseConnection:
|
|
|
449
467
|
max_age = self.settings_dict["CONN_MAX_AGE"]
|
|
450
468
|
self.close_at = None if max_age is None else time.monotonic() + max_age
|
|
451
469
|
self.closed_in_transaction = False
|
|
452
|
-
self.errors_occurred = False
|
|
453
470
|
# New connections are healthy.
|
|
454
471
|
self.health_check_done = True
|
|
455
472
|
# Establish the connection
|
|
@@ -463,8 +480,7 @@ class DatabaseConnection:
|
|
|
463
480
|
def ensure_connection(self) -> None:
|
|
464
481
|
"""Guarantee that a connection to the database is established."""
|
|
465
482
|
if self.connection is None:
|
|
466
|
-
|
|
467
|
-
self.connect()
|
|
483
|
+
self.connect()
|
|
468
484
|
|
|
469
485
|
# ##### PEP-249 connection method wrappers #####
|
|
470
486
|
|
|
@@ -481,23 +497,21 @@ class DatabaseConnection:
|
|
|
481
497
|
def _cursor(self) -> utils.CursorWrapper:
|
|
482
498
|
self.close_if_health_check_failed()
|
|
483
499
|
self.ensure_connection()
|
|
484
|
-
|
|
485
|
-
return self._prepare_cursor(self.create_cursor())
|
|
500
|
+
return self._prepare_cursor(self.create_cursor())
|
|
486
501
|
|
|
487
502
|
def _commit(self) -> None:
|
|
488
503
|
if self.connection is not None:
|
|
489
|
-
with debug_transaction(self, "COMMIT")
|
|
504
|
+
with debug_transaction(self, "COMMIT"):
|
|
490
505
|
return self.connection.commit()
|
|
491
506
|
|
|
492
507
|
def _rollback(self) -> None:
|
|
493
508
|
if self.connection is not None:
|
|
494
|
-
with debug_transaction(self, "ROLLBACK")
|
|
509
|
+
with debug_transaction(self, "ROLLBACK"):
|
|
495
510
|
return self.connection.rollback()
|
|
496
511
|
|
|
497
512
|
def _close(self) -> None:
|
|
498
513
|
if self.connection is not None:
|
|
499
|
-
|
|
500
|
-
return self.connection.close()
|
|
514
|
+
return self.connection.close()
|
|
501
515
|
|
|
502
516
|
# ##### Generic wrappers for PEP-249 connection methods #####
|
|
503
517
|
|
|
@@ -509,16 +523,12 @@ class DatabaseConnection:
|
|
|
509
523
|
"""Commit a transaction and reset the dirty flag."""
|
|
510
524
|
self.validate_no_atomic_block()
|
|
511
525
|
self._commit()
|
|
512
|
-
# A successful commit means that the database connection works.
|
|
513
|
-
self.errors_occurred = False
|
|
514
526
|
self.run_commit_hooks_on_set_autocommit_on = True
|
|
515
527
|
|
|
516
528
|
def rollback(self) -> None:
|
|
517
529
|
"""Roll back a transaction and reset the dirty flag."""
|
|
518
530
|
self.validate_no_atomic_block()
|
|
519
531
|
self._rollback()
|
|
520
|
-
# A successful rollback means that the database connection works.
|
|
521
|
-
self.errors_occurred = False
|
|
522
532
|
self.needs_rollback = False
|
|
523
533
|
self.run_on_commit = []
|
|
524
534
|
|
|
@@ -684,8 +694,8 @@ class DatabaseConnection:
|
|
|
684
694
|
|
|
685
695
|
def close_if_unusable_or_obsolete(self) -> None:
|
|
686
696
|
"""
|
|
687
|
-
Close the current connection if
|
|
688
|
-
or
|
|
697
|
+
Close the current connection if it's broken, improperly restored,
|
|
698
|
+
or has outlived its maximum age.
|
|
689
699
|
"""
|
|
690
700
|
if self.connection is not None:
|
|
691
701
|
self.health_check_done = False
|
|
@@ -695,15 +705,12 @@ class DatabaseConnection:
|
|
|
695
705
|
self.close()
|
|
696
706
|
return
|
|
697
707
|
|
|
698
|
-
# If
|
|
699
|
-
#
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
else:
|
|
705
|
-
self.close()
|
|
706
|
-
return
|
|
708
|
+
# If psycopg detected the connection is dead (e.g. server
|
|
709
|
+
# terminated the backend), close our wrapper so the next
|
|
710
|
+
# request gets a fresh connection.
|
|
711
|
+
if self.connection.closed:
|
|
712
|
+
self.close()
|
|
713
|
+
return
|
|
707
714
|
|
|
708
715
|
if self.close_at is not None and time.monotonic() >= self.close_at:
|
|
709
716
|
self.close()
|
|
@@ -711,14 +718,6 @@ class DatabaseConnection:
|
|
|
711
718
|
|
|
712
719
|
# ##### Miscellaneous #####
|
|
713
720
|
|
|
714
|
-
@cached_property
|
|
715
|
-
def wrap_database_errors(self) -> DatabaseErrorWrapper:
|
|
716
|
-
"""
|
|
717
|
-
Context manager and decorator that re-throws backend-specific database
|
|
718
|
-
exceptions using Plain's common wrappers.
|
|
719
|
-
"""
|
|
720
|
-
return DatabaseErrorWrapper(self)
|
|
721
|
-
|
|
722
721
|
def make_cursor(self, cursor: Any) -> utils.CursorWrapper:
|
|
723
722
|
"""Create a cursor without debug logging."""
|
|
724
723
|
return utils.CursorWrapper(cursor, self)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from collections.abc import Generator
|
|
4
|
+
from contextlib import contextmanager
|
|
3
5
|
from contextvars import ContextVar
|
|
4
6
|
from typing import TYPE_CHECKING, Any, TypedDict
|
|
5
7
|
|
|
@@ -75,3 +77,22 @@ def get_connection() -> DatabaseConnection:
|
|
|
75
77
|
def has_connection() -> bool:
|
|
76
78
|
"""Check if a database connection exists in the current context."""
|
|
77
79
|
return _db_conn.get() is not None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@contextmanager
|
|
83
|
+
def read_only() -> Generator[None]:
|
|
84
|
+
"""Set the current database connection to read-only for the duration of this block.
|
|
85
|
+
|
|
86
|
+
Any INSERT/UPDATE/DELETE/DDL will raise a database error. This applies
|
|
87
|
+
to all queries in the block — both explicit transactions and implicit
|
|
88
|
+
autocommit queries.
|
|
89
|
+
"""
|
|
90
|
+
conn = get_connection()
|
|
91
|
+
conn.set_read_only(True)
|
|
92
|
+
try:
|
|
93
|
+
yield
|
|
94
|
+
finally:
|
|
95
|
+
try:
|
|
96
|
+
conn.set_read_only(False)
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
@@ -5,18 +5,6 @@ from typing import Any
|
|
|
5
5
|
from plain import signals
|
|
6
6
|
|
|
7
7
|
from .connections import get_connection, has_connection
|
|
8
|
-
from .exceptions import (
|
|
9
|
-
DatabaseError,
|
|
10
|
-
DatabaseErrorWrapper,
|
|
11
|
-
DataError,
|
|
12
|
-
Error,
|
|
13
|
-
IntegrityError,
|
|
14
|
-
InterfaceError,
|
|
15
|
-
InternalError,
|
|
16
|
-
NotSupportedError,
|
|
17
|
-
OperationalError,
|
|
18
|
-
ProgrammingError,
|
|
19
|
-
)
|
|
20
8
|
|
|
21
9
|
PLAIN_VERSION_PICKLE_KEY = "_plain_version"
|
|
22
10
|
|
|
@@ -45,15 +33,5 @@ __all__ = [
|
|
|
45
33
|
"get_connection",
|
|
46
34
|
"has_connection",
|
|
47
35
|
"PLAIN_VERSION_PICKLE_KEY",
|
|
48
|
-
"Error",
|
|
49
|
-
"InterfaceError",
|
|
50
|
-
"DatabaseError",
|
|
51
|
-
"DataError",
|
|
52
|
-
"OperationalError",
|
|
53
|
-
"IntegrityError",
|
|
54
|
-
"InternalError",
|
|
55
|
-
"ProgrammingError",
|
|
56
|
-
"NotSupportedError",
|
|
57
|
-
"DatabaseErrorWrapper",
|
|
58
36
|
"close_old_connections",
|
|
59
37
|
]
|
|
@@ -7,11 +7,12 @@ from itertools import chain
|
|
|
7
7
|
from operator import attrgetter, or_
|
|
8
8
|
from typing import TYPE_CHECKING, Any
|
|
9
9
|
|
|
10
|
+
import psycopg
|
|
11
|
+
|
|
10
12
|
from plain.postgres import (
|
|
11
13
|
query_utils,
|
|
12
14
|
transaction,
|
|
13
15
|
)
|
|
14
|
-
from plain.postgres.db import IntegrityError
|
|
15
16
|
from plain.postgres.meta import Meta
|
|
16
17
|
from plain.postgres.query import QuerySet
|
|
17
18
|
from plain.postgres.sql import DeleteQuery, UpdateQuery
|
|
@@ -28,16 +29,16 @@ if TYPE_CHECKING:
|
|
|
28
29
|
_LAZY_ON_DELETE: set[Callable[..., Any]] = set()
|
|
29
30
|
|
|
30
31
|
|
|
31
|
-
class ProtectedError(IntegrityError):
|
|
32
|
+
class ProtectedError(psycopg.IntegrityError):
|
|
32
33
|
def __init__(self, msg: str, protected_objects: Iterable[Any]) -> None:
|
|
33
34
|
self.protected_objects = protected_objects
|
|
34
|
-
super().__init__(msg
|
|
35
|
+
super().__init__(msg)
|
|
35
36
|
|
|
36
37
|
|
|
37
|
-
class RestrictedError(IntegrityError):
|
|
38
|
+
class RestrictedError(psycopg.IntegrityError):
|
|
38
39
|
def __init__(self, msg: str, restricted_objects: Iterable[Any]) -> None:
|
|
39
40
|
self.restricted_objects = restricted_objects
|
|
40
|
-
super().__init__(msg
|
|
41
|
+
super().__init__(msg)
|
|
41
42
|
|
|
42
43
|
|
|
43
44
|
def CASCADE(collector: Collector, field: RelatedField, sub_objs: Any) -> None:
|
|
@@ -13,11 +13,11 @@ from collections.abc import Callable, Iterable
|
|
|
13
13
|
from functools import lru_cache, partial
|
|
14
14
|
from typing import TYPE_CHECKING, Any
|
|
15
15
|
|
|
16
|
+
import psycopg
|
|
16
17
|
from psycopg.types import numeric
|
|
17
18
|
from psycopg.types.json import Jsonb
|
|
18
19
|
|
|
19
20
|
from plain.postgres.constants import OnConflict
|
|
20
|
-
from plain.postgres.db import NotSupportedError
|
|
21
21
|
from plain.postgres.utils import split_tzname_delta
|
|
22
22
|
from plain.utils import timezone
|
|
23
23
|
from plain.utils.regex_helper import _lazy_re_compile
|
|
@@ -583,7 +583,7 @@ def window_frame_range_start_end(
|
|
|
583
583
|
start_, end_ = window_frame_rows_start_end(start, end)
|
|
584
584
|
# PostgreSQL only supports UNBOUNDED with PRECEDING/FOLLOWING
|
|
585
585
|
if (start and start < 0) or (end and end > 0):
|
|
586
|
-
raise NotSupportedError(
|
|
586
|
+
raise psycopg.NotSupportedError(
|
|
587
587
|
"PostgreSQL only supports UNBOUNDED together with PRECEDING and FOLLOWING."
|
|
588
588
|
)
|
|
589
589
|
return start_, end_
|
|
@@ -1,14 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from
|
|
4
|
-
from typing import TYPE_CHECKING, Any, TypeVar, cast
|
|
5
|
-
|
|
6
|
-
import psycopg
|
|
7
|
-
|
|
8
|
-
if TYPE_CHECKING:
|
|
9
|
-
from plain.postgres.connection import DatabaseConnection
|
|
10
|
-
|
|
11
|
-
F = TypeVar("F", bound=Callable[..., Any])
|
|
3
|
+
from typing import Any, cast
|
|
12
4
|
|
|
13
5
|
# MARK: Database Query Exceptions
|
|
14
6
|
|
|
@@ -119,99 +111,3 @@ class MultipleObjectsReturnedDescriptor:
|
|
|
119
111
|
|
|
120
112
|
def __set__(self, instance: Any, value: Any) -> None:
|
|
121
113
|
raise AttributeError("Cannot set MultipleObjectsReturned")
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
# MARK: Database Exceptions (PEP-249)
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
class Error(Exception):
|
|
128
|
-
pass
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
class InterfaceError(Error):
|
|
132
|
-
pass
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
class DatabaseError(Error):
|
|
136
|
-
pass
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
class DataError(DatabaseError):
|
|
140
|
-
pass
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
class OperationalError(DatabaseError):
|
|
144
|
-
pass
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
class IntegrityError(DatabaseError):
|
|
148
|
-
pass
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
class InternalError(DatabaseError):
|
|
152
|
-
pass
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
class ProgrammingError(DatabaseError):
|
|
156
|
-
pass
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
class NotSupportedError(DatabaseError):
|
|
160
|
-
pass
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
class DatabaseErrorWrapper:
|
|
164
|
-
"""
|
|
165
|
-
Context manager and decorator that reraises backend-specific database
|
|
166
|
-
exceptions using Plain's common wrappers.
|
|
167
|
-
"""
|
|
168
|
-
|
|
169
|
-
def __init__(self, wrapper: DatabaseConnection) -> None:
|
|
170
|
-
"""
|
|
171
|
-
wrapper is a database wrapper.
|
|
172
|
-
|
|
173
|
-
It must have a Database attribute defining PEP-249 exceptions.
|
|
174
|
-
"""
|
|
175
|
-
self.wrapper = wrapper
|
|
176
|
-
|
|
177
|
-
def __enter__(self) -> None:
|
|
178
|
-
pass
|
|
179
|
-
|
|
180
|
-
def __exit__(
|
|
181
|
-
self,
|
|
182
|
-
exc_type: type[BaseException] | None,
|
|
183
|
-
exc_value: BaseException | None,
|
|
184
|
-
traceback: Any,
|
|
185
|
-
) -> None:
|
|
186
|
-
if exc_type is None:
|
|
187
|
-
return
|
|
188
|
-
for plain_exc_type in (
|
|
189
|
-
DataError,
|
|
190
|
-
OperationalError,
|
|
191
|
-
IntegrityError,
|
|
192
|
-
InternalError,
|
|
193
|
-
ProgrammingError,
|
|
194
|
-
NotSupportedError,
|
|
195
|
-
DatabaseError,
|
|
196
|
-
InterfaceError,
|
|
197
|
-
Error,
|
|
198
|
-
):
|
|
199
|
-
db_exc_type = getattr(psycopg, plain_exc_type.__name__)
|
|
200
|
-
if issubclass(exc_type, db_exc_type):
|
|
201
|
-
plain_exc_value = (
|
|
202
|
-
plain_exc_type(*exc_value.args) if exc_value else plain_exc_type()
|
|
203
|
-
)
|
|
204
|
-
# Only set the 'errors_occurred' flag for errors that may make
|
|
205
|
-
# the connection unusable.
|
|
206
|
-
if plain_exc_type not in (DataError, IntegrityError):
|
|
207
|
-
self.wrapper.errors_occurred = True
|
|
208
|
-
raise plain_exc_value.with_traceback(traceback) from exc_value
|
|
209
|
-
|
|
210
|
-
def __call__(self, func: F) -> F:
|
|
211
|
-
# Note that we are intentionally not using @wraps here for performance
|
|
212
|
-
# reasons. Refs #21109.
|
|
213
|
-
def inner(*args: Any, **kwargs: Any) -> Any:
|
|
214
|
-
with self:
|
|
215
|
-
return func(*args, **kwargs)
|
|
216
|
-
|
|
217
|
-
return cast(F, inner)
|
|
@@ -11,9 +11,10 @@ from types import NoneType
|
|
|
11
11
|
from typing import TYPE_CHECKING, Any, Protocol, Self, runtime_checkable
|
|
12
12
|
from uuid import UUID
|
|
13
13
|
|
|
14
|
+
import psycopg
|
|
15
|
+
|
|
14
16
|
from plain.postgres import fields
|
|
15
17
|
from plain.postgres.constants import LOOKUP_SEP
|
|
16
|
-
from plain.postgres.db import NotSupportedError
|
|
17
18
|
from plain.postgres.dialect import (
|
|
18
19
|
CURRENT_ROW,
|
|
19
20
|
FOLLOWING,
|
|
@@ -877,7 +878,7 @@ class ResolvedOuterRef(F):
|
|
|
877
878
|
def resolve_expression(self, *args: Any, **kwargs: Any) -> Any:
|
|
878
879
|
col = super().resolve_expression(*args, **kwargs)
|
|
879
880
|
if col.contains_over_clause:
|
|
880
|
-
raise NotSupportedError(
|
|
881
|
+
raise psycopg.NotSupportedError(
|
|
881
882
|
f"Referencing outer query window expression is not supported: "
|
|
882
883
|
f"{self.name}."
|
|
883
884
|
)
|
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from typing import Any
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
import psycopg
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
class AmbiguityError(Exception):
|
|
@@ -50,5 +50,5 @@ class NodeNotFoundError(LookupError):
|
|
|
50
50
|
return f"NodeNotFoundError({self.node!r})"
|
|
51
51
|
|
|
52
52
|
|
|
53
|
-
class MigrationSchemaMissing(DatabaseError):
|
|
53
|
+
class MigrationSchemaMissing(psycopg.DatabaseError):
|
|
54
54
|
pass
|