flask-postgresql-audit 1.1.2__tar.gz → 1.2.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.
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/CHANGES.md +20 -0
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/PKG-INFO +66 -4
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/README.md +60 -2
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/__init__.py +1 -1
- flask_postgresql_audit-1.2.0/flask_postgresql_audit/alembic/__init__.py +9 -0
- flask_postgresql_audit-1.2.0/flask_postgresql_audit/alembic/constraint.py +105 -0
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/alembic/entities.py +10 -3
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/alembic/setup.py +41 -19
- flask_postgresql_audit-1.2.0/flask_postgresql_audit/alembic/xid8.py +95 -0
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/base.py +4 -8
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/core.py +6 -4
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/extensions/document_staging/model.py +2 -2
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/models.py +5 -7
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/templates/create_activity.sql +4 -4
- flask_postgresql_audit-1.2.0/flask_postgresql_audit/templates/jsonb_diff.sql +42 -0
- flask_postgresql_audit-1.2.0/flask_postgresql_audit/types.py +29 -0
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/pyproject.toml +10 -0
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/tests/app.py +7 -5
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/tests/conftest.py +11 -1
- flask_postgresql_audit-1.2.0/tests/test_alembic_xid8.py +224 -0
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/tests/test_defaults.py +10 -1
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/tests/test_operators.py +48 -0
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/tests/utils.py +0 -1
- flask_postgresql_audit-1.1.2/flask_postgresql_audit/alembic/__init__.py +0 -7
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/.gitignore +0 -0
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/LICENSE +0 -0
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/alembic/schema.py +0 -0
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/extensions/__init__.py +0 -0
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/extensions/alembic_utils/__init__.py +0 -0
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/extensions/alembic_utils/pg_aggregate.py +0 -0
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/extensions/document_staging/__init__.py +0 -0
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/extensions/document_staging/enum.py +0 -0
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/extensions/document_staging/listener.py +0 -0
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/templates/audit_trigger_delete.sql +0 -0
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/templates/audit_trigger_insert.sql +0 -0
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/templates/audit_trigger_update.sql +0 -0
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/templates/get_pk_values.sql +0 -0
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/templates/get_setting.sql +0 -0
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/templates/get_table_relid.sql +0 -0
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/templates/jsonb_array_subtract.sql +0 -0
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/templates/jsonb_subtract.sql +0 -0
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/typing.py +0 -0
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/utils.py +0 -0
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/tests/__init__.py +0 -0
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/tests/migrations/README +0 -0
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/tests/migrations/alembic.ini +0 -0
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/tests/migrations/env.py +0 -0
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/tests/migrations/script.py.mako +0 -0
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/tests/test_document_staging.py +0 -0
- {flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/tests/test_pg_aggregate.py +0 -0
|
@@ -3,6 +3,26 @@
|
|
|
3
3
|
Here you can see the full list of changes between each Flask-PostgreSQL-Audit
|
|
4
4
|
release.
|
|
5
5
|
|
|
6
|
+
## 1.2.0 (2026-11-13)
|
|
7
|
+
|
|
8
|
+
- Add support for psycopg 3 and make it the default PostgreSQL driver.
|
|
9
|
+
- Keep psycopg2 support available through the `psycopg2` optional dependency extra.
|
|
10
|
+
- Add support for Python 3.14 and 3.15.
|
|
11
|
+
- Run the test suite against both psycopg 3 and psycopg2.
|
|
12
|
+
- Add pre-commit hooks for Ruff and the ty type checker.
|
|
13
|
+
- Add PostgreSQL 15 through 18 as a tox matrix dimension.
|
|
14
|
+
- Run the tox matrix in parallel with isolated per-worker databases and migrations, capped by the number of PostgreSQL containers.
|
|
15
|
+
- Add `scripts/run_tox_matrix.py` to run the full tox matrix with one command, including temporary PostgreSQL containers.
|
|
16
|
+
- Replace deprecated `txid_current()` with `pg_current_xact_id()` and store `native_transaction_id` as PostgreSQL `xid8`. Existing `bigint` columns are migrated with `USING native_transaction_id::text::xid8` when `reorder_migration_ops` is used.
|
|
17
|
+
- Replace the `native_transaction_id` exclusion constraint with a unique constraint. This keeps one transaction row per database transaction without requiring the `btree_gist` extension, which is no longer created.
|
|
18
|
+
- Add Alembic autogenerate support for `xid8`, including type reflection and `USING` clauses when `native_transaction_id` changes between `bigint` and `xid8`.
|
|
19
|
+
- Drop the legacy `native_transaction_id` exclusion constraint during autogenerate and restore it on downgrade, so the generated migration runs without manual edits.
|
|
20
|
+
- Re-register audit entities after blind create detection, keeping repeated autogenerate runs in the same process accurate.
|
|
21
|
+
|
|
22
|
+
## 1.1.3 (2026-08-21)
|
|
23
|
+
|
|
24
|
+
- Add `jsonb_diff()` function to recursively compute differences across JSONB objects and arrays.
|
|
25
|
+
|
|
6
26
|
## 1.1.2 (2026-08-21)
|
|
7
27
|
|
|
8
28
|
- Add dedicated `jsonb_array_subtract()` function to support set difference operations on JSONB arrays.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: flask-postgresql-audit
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Versioning and auditing extension for PostgreSQL and SQLAlchemy.
|
|
5
5
|
Project-URL: Homepage, https://github.com/higamigu/flask-postgresql-audit
|
|
6
6
|
Author-email: Higamigu <higamigu@gmail.com>, Konsta Vesterinen <konsta@fastmonkeys.com>
|
|
@@ -15,6 +15,8 @@ Classifier: Programming Language :: Python :: 3
|
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.11
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.12
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
18
20
|
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
19
21
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
22
|
Requires-Python: >=3.11
|
|
@@ -22,7 +24,9 @@ Requires-Dist: alembic-utils>=0.8.8
|
|
|
22
24
|
Requires-Dist: alembic>=1.18.0
|
|
23
25
|
Requires-Dist: flask-sqlalchemy>=3.1.1
|
|
24
26
|
Requires-Dist: flask>=3.1.2
|
|
25
|
-
Requires-Dist:
|
|
27
|
+
Requires-Dist: psycopg[binary]>=3.2
|
|
28
|
+
Provides-Extra: psycopg2
|
|
29
|
+
Requires-Dist: psycopg2>=2.9.11; extra == 'psycopg2'
|
|
26
30
|
Description-Content-Type: text/markdown
|
|
27
31
|
|
|
28
32
|
# Flask-PostgreSQL-Audit
|
|
@@ -55,6 +59,30 @@ or install directly from this repo
|
|
|
55
59
|
uv add git+https://github.com/higamigu/flask-postgresql-audit --tag v1.1.0
|
|
56
60
|
```
|
|
57
61
|
|
|
62
|
+
The package installs [psycopg 3](https://www.psycopg.org/psycopg3/) as its default
|
|
63
|
+
PostgreSQL driver. If your application uses the legacy `psycopg2` driver,
|
|
64
|
+
install it with the optional extra:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
pip install "flask-postgresql-audit[psycopg2]"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Database Drivers
|
|
71
|
+
|
|
72
|
+
Both psycopg 3 and psycopg2 are supported. Select the driver in your
|
|
73
|
+
SQLAlchemy database URL:
|
|
74
|
+
|
|
75
|
+
``` python
|
|
76
|
+
# psycopg 3 (default)
|
|
77
|
+
SQLALCHEMY_DATABASE_URI = "postgresql+psycopg://user:password@localhost/database"
|
|
78
|
+
|
|
79
|
+
# psycopg2
|
|
80
|
+
SQLALCHEMY_DATABASE_URI = "postgresql+psycopg2://user:password@localhost/database"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The extension requires Python 3.11 or newer and is tested on Python 3.11 through
|
|
84
|
+
3.15.
|
|
85
|
+
|
|
58
86
|
## Usage
|
|
59
87
|
|
|
60
88
|
``` python
|
|
@@ -268,6 +296,13 @@ context.configure(
|
|
|
268
296
|
)
|
|
269
297
|
```
|
|
270
298
|
|
|
299
|
+
`reorder_migration_ops` also adds the required `USING` clauses when
|
|
300
|
+
`native_transaction_id` is migrated between `bigint` and `xid8`. When the
|
|
301
|
+
database still has the `1.0.x` exclusion constraint on
|
|
302
|
+
`pga_transaction.native_transaction_id`, autogenerate drops it before creating
|
|
303
|
+
the replacement unique constraint and restores it again on downgrade, so the
|
|
304
|
+
generated migration can be applied as-is.
|
|
305
|
+
|
|
271
306
|
## Extensions
|
|
272
307
|
|
|
273
308
|
### Document Staging
|
|
@@ -322,5 +357,32 @@ Automatically detects and tracks custom `CREATE AGGREGATE` definitions during `a
|
|
|
322
357
|
git clone https://github.com/higamigu/flask-postgresql-audit.git
|
|
323
358
|
cd flask-postgresql-audit
|
|
324
359
|
pip install tox
|
|
325
|
-
|
|
326
|
-
|
|
360
|
+
python scripts/run_tox_matrix.py
|
|
361
|
+
|
|
362
|
+
The script provisions PostgreSQL 15 through 18 containers, distributes the tox
|
|
363
|
+
environments across 4 parallel workers by default, and prints a pass/skip/fail
|
|
364
|
+
summary. Each worker gets its own database and Alembic migration directory, so
|
|
365
|
+
parallel runs cannot reset each other's schemas. Worker count is capped at the
|
|
366
|
+
number of PostgreSQL containers in the matrix (currently 4). Use `--workers N`
|
|
367
|
+
to change concurrency, `--verbose` to stream test output, `--fail-fast` to stop
|
|
368
|
+
at the first failure (sequential), `--no-postgres` to use an existing database
|
|
369
|
+
(sequential), or `--keep-postgres` to leave the containers running for
|
|
370
|
+
debugging. Install missing interpreters with `mise install python@3.13` or
|
|
371
|
+
`uv python install 3.13` to include them.
|
|
372
|
+
|
|
373
|
+
To run a single environment, provide a matching PostgreSQL server yourself:
|
|
374
|
+
|
|
375
|
+
tox -e py315-pg18-schemaDefault-psycopg
|
|
376
|
+
|
|
377
|
+
The default driver is psycopg 3. For psycopg2, select the matching environment:
|
|
378
|
+
|
|
379
|
+
tox -e py315-pg18-schemaDefault-psycopg2
|
|
380
|
+
|
|
381
|
+
## Pre-commit hooks
|
|
382
|
+
|
|
383
|
+
The repository uses [pre-commit](https://pre-commit.com/) to run Ruff and the
|
|
384
|
+
[ty](https://github.com/astral-sh/ty) type checker before each commit:
|
|
385
|
+
|
|
386
|
+
pip install pre-commit
|
|
387
|
+
pre-commit install
|
|
388
|
+
pre-commit run --all-files
|
|
@@ -28,6 +28,30 @@ or install directly from this repo
|
|
|
28
28
|
uv add git+https://github.com/higamigu/flask-postgresql-audit --tag v1.1.0
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
+
The package installs [psycopg 3](https://www.psycopg.org/psycopg3/) as its default
|
|
32
|
+
PostgreSQL driver. If your application uses the legacy `psycopg2` driver,
|
|
33
|
+
install it with the optional extra:
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
pip install "flask-postgresql-audit[psycopg2]"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Database Drivers
|
|
40
|
+
|
|
41
|
+
Both psycopg 3 and psycopg2 are supported. Select the driver in your
|
|
42
|
+
SQLAlchemy database URL:
|
|
43
|
+
|
|
44
|
+
``` python
|
|
45
|
+
# psycopg 3 (default)
|
|
46
|
+
SQLALCHEMY_DATABASE_URI = "postgresql+psycopg://user:password@localhost/database"
|
|
47
|
+
|
|
48
|
+
# psycopg2
|
|
49
|
+
SQLALCHEMY_DATABASE_URI = "postgresql+psycopg2://user:password@localhost/database"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The extension requires Python 3.11 or newer and is tested on Python 3.11 through
|
|
53
|
+
3.15.
|
|
54
|
+
|
|
31
55
|
## Usage
|
|
32
56
|
|
|
33
57
|
``` python
|
|
@@ -241,6 +265,13 @@ context.configure(
|
|
|
241
265
|
)
|
|
242
266
|
```
|
|
243
267
|
|
|
268
|
+
`reorder_migration_ops` also adds the required `USING` clauses when
|
|
269
|
+
`native_transaction_id` is migrated between `bigint` and `xid8`. When the
|
|
270
|
+
database still has the `1.0.x` exclusion constraint on
|
|
271
|
+
`pga_transaction.native_transaction_id`, autogenerate drops it before creating
|
|
272
|
+
the replacement unique constraint and restores it again on downgrade, so the
|
|
273
|
+
generated migration can be applied as-is.
|
|
274
|
+
|
|
244
275
|
## Extensions
|
|
245
276
|
|
|
246
277
|
### Document Staging
|
|
@@ -295,5 +326,32 @@ Automatically detects and tracks custom `CREATE AGGREGATE` definitions during `a
|
|
|
295
326
|
git clone https://github.com/higamigu/flask-postgresql-audit.git
|
|
296
327
|
cd flask-postgresql-audit
|
|
297
328
|
pip install tox
|
|
298
|
-
|
|
299
|
-
|
|
329
|
+
python scripts/run_tox_matrix.py
|
|
330
|
+
|
|
331
|
+
The script provisions PostgreSQL 15 through 18 containers, distributes the tox
|
|
332
|
+
environments across 4 parallel workers by default, and prints a pass/skip/fail
|
|
333
|
+
summary. Each worker gets its own database and Alembic migration directory, so
|
|
334
|
+
parallel runs cannot reset each other's schemas. Worker count is capped at the
|
|
335
|
+
number of PostgreSQL containers in the matrix (currently 4). Use `--workers N`
|
|
336
|
+
to change concurrency, `--verbose` to stream test output, `--fail-fast` to stop
|
|
337
|
+
at the first failure (sequential), `--no-postgres` to use an existing database
|
|
338
|
+
(sequential), or `--keep-postgres` to leave the containers running for
|
|
339
|
+
debugging. Install missing interpreters with `mise install python@3.13` or
|
|
340
|
+
`uv python install 3.13` to include them.
|
|
341
|
+
|
|
342
|
+
To run a single environment, provide a matching PostgreSQL server yourself:
|
|
343
|
+
|
|
344
|
+
tox -e py315-pg18-schemaDefault-psycopg
|
|
345
|
+
|
|
346
|
+
The default driver is psycopg 3. For psycopg2, select the matching environment:
|
|
347
|
+
|
|
348
|
+
tox -e py315-pg18-schemaDefault-psycopg2
|
|
349
|
+
|
|
350
|
+
## Pre-commit hooks
|
|
351
|
+
|
|
352
|
+
The repository uses [pre-commit](https://pre-commit.com/) to run Ruff and the
|
|
353
|
+
[ty](https://github.com/astral-sh/ty) type checker before each commit:
|
|
354
|
+
|
|
355
|
+
pip install pre-commit
|
|
356
|
+
pre-commit install
|
|
357
|
+
pre-commit run --all-files
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# ruff: noqa: F401
|
|
2
|
+
from .constraint import NativeTransactionConstraintOp, legacy_constraint_op
|
|
3
|
+
from .setup import (
|
|
4
|
+
chain_revision_directives,
|
|
5
|
+
register_triggers,
|
|
6
|
+
reorder_migration_ops,
|
|
7
|
+
setup_db,
|
|
8
|
+
)
|
|
9
|
+
from .xid8 import apply_xid8_type_changes, register_xid8_type
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Alembic support for the native transaction ID constraint swap.
|
|
2
|
+
|
|
3
|
+
Version ``1.1.x`` replaced the ``gist`` exclusion constraint on
|
|
4
|
+
``pga_transaction.native_transaction_id`` with a plain unique constraint.
|
|
5
|
+
Alembic never reflects exclusion constraints, so autogenerate cannot see the
|
|
6
|
+
legacy constraint and would emit ``create_unique_constraint`` while the old
|
|
7
|
+
constraint still owns the name, failing with a duplicate constraint error.
|
|
8
|
+
|
|
9
|
+
The operations below drop the legacy constraint before the ``bigint`` to
|
|
10
|
+
``xid8`` type change and restore it again on downgrade.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import typing as t
|
|
16
|
+
|
|
17
|
+
from alembic.autogenerate import renderers
|
|
18
|
+
from alembic.operations import MigrateOperation
|
|
19
|
+
from sqlalchemy import Connection, text
|
|
20
|
+
|
|
21
|
+
if t.TYPE_CHECKING:
|
|
22
|
+
from alembic.autogenerate.api import AutogenContext
|
|
23
|
+
|
|
24
|
+
CONSTRAINT_NAME = "pga_transaction_unique_native_tx_id"
|
|
25
|
+
TABLE_NAME = "pga_transaction"
|
|
26
|
+
EXCLUSION_ELEMENTS = (
|
|
27
|
+
"native_transaction_id WITH =",
|
|
28
|
+
"tsrange(issued_at - INTERVAL '1 hour', issued_at) WITH &&",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class NativeTransactionConstraintOp(MigrateOperation):
|
|
33
|
+
"""Drop or create the legacy native transaction ID exclusion constraint."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, *, drop_sql: str, create_sql: str, drop: bool = True) -> None:
|
|
36
|
+
self.drop_sql = drop_sql
|
|
37
|
+
self.create_sql = create_sql
|
|
38
|
+
self.drop = drop
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def sqltext(self) -> str:
|
|
42
|
+
return self.drop_sql if self.drop else self.create_sql
|
|
43
|
+
|
|
44
|
+
def reverse(self) -> NativeTransactionConstraintOp:
|
|
45
|
+
return NativeTransactionConstraintOp(
|
|
46
|
+
drop_sql=self.drop_sql,
|
|
47
|
+
create_sql=self.create_sql,
|
|
48
|
+
drop=not self.drop,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def to_diff_tuple(self) -> tuple[t.Any, ...]:
|
|
52
|
+
verb = (
|
|
53
|
+
"drop_exclusion_constraint" if self.drop else "create_exclusion_constraint"
|
|
54
|
+
)
|
|
55
|
+
return (verb, TABLE_NAME, CONSTRAINT_NAME)
|
|
56
|
+
|
|
57
|
+
def describe(self) -> str: # pragma: no cover - debugging aid
|
|
58
|
+
return self.sqltext
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@renderers.dispatch_for(NativeTransactionConstraintOp)
|
|
62
|
+
def render_native_transaction_constraint(
|
|
63
|
+
autogen_context: AutogenContext, op: NativeTransactionConstraintOp
|
|
64
|
+
) -> str:
|
|
65
|
+
return f"op.execute({op.sqltext!r})"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def legacy_constraint_op(
|
|
69
|
+
connection: Connection, schema_name: str | None
|
|
70
|
+
) -> NativeTransactionConstraintOp | None:
|
|
71
|
+
"""Return an operation dropping the legacy exclusion constraint if present."""
|
|
72
|
+
schema = schema_name or "public"
|
|
73
|
+
query = text(
|
|
74
|
+
"""
|
|
75
|
+
SELECT c.contype
|
|
76
|
+
FROM pg_constraint AS c
|
|
77
|
+
JOIN pg_class AS t ON t.oid = c.conrelid
|
|
78
|
+
JOIN pg_namespace AS n ON n.oid = t.relnamespace
|
|
79
|
+
WHERE t.relname = :table
|
|
80
|
+
AND n.nspname = :schema
|
|
81
|
+
AND c.conname = :constraint
|
|
82
|
+
"""
|
|
83
|
+
)
|
|
84
|
+
contype = connection.scalar(
|
|
85
|
+
query,
|
|
86
|
+
{
|
|
87
|
+
"table": TABLE_NAME,
|
|
88
|
+
"schema": schema,
|
|
89
|
+
"constraint": CONSTRAINT_NAME,
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
if contype != "x":
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
preparer = connection.dialect.identifier_preparer
|
|
96
|
+
table = f"{preparer.quote_schema(schema)}.{preparer.quote(TABLE_NAME)}"
|
|
97
|
+
constraint = preparer.quote(CONSTRAINT_NAME)
|
|
98
|
+
|
|
99
|
+
return NativeTransactionConstraintOp(
|
|
100
|
+
drop_sql=f"ALTER TABLE {table} DROP CONSTRAINT {constraint}",
|
|
101
|
+
create_sql=(
|
|
102
|
+
f"ALTER TABLE {table} ADD CONSTRAINT {constraint}"
|
|
103
|
+
f" EXCLUDE USING gist ({', '.join(EXCLUSION_ELEMENTS)})"
|
|
104
|
+
),
|
|
105
|
+
)
|
|
@@ -1,11 +1,8 @@
|
|
|
1
|
-
from alembic_utils.pg_extension import PGExtension
|
|
2
1
|
from alembic_utils.pg_function import PGFunction
|
|
3
2
|
from alembic_utils.pg_trigger import PGTrigger
|
|
4
3
|
|
|
5
4
|
from ..utils import parse_template
|
|
6
5
|
|
|
7
|
-
btree_gist = PGExtension(schema="public", signature="btree_gist")
|
|
8
|
-
|
|
9
6
|
|
|
10
7
|
def create_activity_factory(**context):
|
|
11
8
|
sql = parse_template("create_activity.sql", **context)
|
|
@@ -90,3 +87,13 @@ def jsonb_array_subtract_factory(**context):
|
|
|
90
87
|
signature="jsonb_array_subtract(arg1 jsonb, arg2 jsonb)",
|
|
91
88
|
definition=sql["definition"],
|
|
92
89
|
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def jsonb_diff_factory(**context):
|
|
93
|
+
context.setdefault("jsonb_subtract_join_type", "LEFT")
|
|
94
|
+
sql = parse_template("jsonb_diff.sql", **context)
|
|
95
|
+
return PGFunction(
|
|
96
|
+
schema=context["schema_name"],
|
|
97
|
+
signature="jsonb_diff(arg1 jsonb, arg2 jsonb)",
|
|
98
|
+
definition=sql["definition"],
|
|
99
|
+
)
|
|
@@ -12,7 +12,9 @@ from sqlalchemy import Connection, text
|
|
|
12
12
|
from sqlalchemy.orm import Session
|
|
13
13
|
|
|
14
14
|
from . import entities
|
|
15
|
+
from .constraint import legacy_constraint_op
|
|
15
16
|
from .schema import SchemaCreate, SchemaDrop
|
|
17
|
+
from .xid8 import apply_xid8_type_changes, register_xid8_type
|
|
16
18
|
|
|
17
19
|
if TYPE_CHECKING:
|
|
18
20
|
from alembic_utils.replaceable_entity import ReplaceableEntity
|
|
@@ -22,8 +24,28 @@ if TYPE_CHECKING:
|
|
|
22
24
|
|
|
23
25
|
logger = logging.getLogger("pg_audit.setup")
|
|
24
26
|
|
|
27
|
+
# Entities temporarily removed from the alembic_utils registry while blind
|
|
28
|
+
# create operations are generated. They are re-registered after autogenerate
|
|
29
|
+
# so a second autogenerate in the same process (test suites, compare_metadata)
|
|
30
|
+
# still sees them.
|
|
31
|
+
_blind_entities: list["ReplaceableEntity"] = []
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def restore_blind_entities() -> None:
|
|
35
|
+
"""Re-register entities hidden by a previous blind-create comparison."""
|
|
36
|
+
if _blind_entities:
|
|
37
|
+
registry.register(_blind_entities)
|
|
38
|
+
_blind_entities.clear()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _hide_registry_entity(entity: "ReplaceableEntity") -> None:
|
|
42
|
+
hidden = registry._entities.pop(entity.identity, None)
|
|
43
|
+
if hidden is not None:
|
|
44
|
+
_blind_entities.append(hidden)
|
|
45
|
+
|
|
25
46
|
|
|
26
47
|
def setup_db(audit: "PostgreSQLAudit"):
|
|
48
|
+
register_xid8_type()
|
|
27
49
|
register_core_entities(audit)
|
|
28
50
|
register_triggers(audit)
|
|
29
51
|
register_entities(audit.pg_audit_entities)
|
|
@@ -34,6 +56,8 @@ def setup_db(audit: "PostgreSQLAudit"):
|
|
|
34
56
|
upgrade_ops: ops.UpgradeOps,
|
|
35
57
|
schemas: set[None] | set[str | None],
|
|
36
58
|
) -> None:
|
|
59
|
+
restore_blind_entities()
|
|
60
|
+
|
|
37
61
|
if connection := autogen_context.connection:
|
|
38
62
|
check_schema = """
|
|
39
63
|
SELECT TRUE FROM information_schema.schemata
|
|
@@ -44,9 +68,17 @@ def setup_db(audit: "PostgreSQLAudit"):
|
|
|
44
68
|
):
|
|
45
69
|
upgrade_ops.ops.append(SchemaCreate(audit.context["schema_name"]))
|
|
46
70
|
|
|
71
|
+
# Alembic cannot reflect exclusion constraints, so the legacy
|
|
72
|
+
# native transaction ID constraint of 1.0.x must be dropped
|
|
73
|
+
# explicitly before the unique constraint of 1.1.x is created.
|
|
74
|
+
if constraint_op := legacy_constraint_op(
|
|
75
|
+
connection, audit.context["schema_name"]
|
|
76
|
+
):
|
|
77
|
+
upgrade_ops.ops.append(constraint_op)
|
|
78
|
+
|
|
47
79
|
for ent in audit.pg_audit_entities:
|
|
48
80
|
if op := get_blind_migration_op(ent, connection):
|
|
49
|
-
|
|
81
|
+
_hide_registry_entity(ent)
|
|
50
82
|
upgrade_ops.ops.append(op)
|
|
51
83
|
|
|
52
84
|
|
|
@@ -54,10 +86,12 @@ def reorder_migration_ops(context, revision, directives: list[MigrationScript]):
|
|
|
54
86
|
"""
|
|
55
87
|
Alembic directive listener that reorders upgrade operations into correct
|
|
56
88
|
dependency order:
|
|
57
|
-
1. Schema creation
|
|
89
|
+
1. Schema creation
|
|
58
90
|
2. Tables, indexes, foreign keys, etc.
|
|
59
91
|
3. Triggers & Replaceable audit entities
|
|
60
92
|
"""
|
|
93
|
+
apply_xid8_type_changes(directives)
|
|
94
|
+
|
|
61
95
|
for directive in directives:
|
|
62
96
|
if not hasattr(directive, "upgrade_ops") or directive.upgrade_ops is None:
|
|
63
97
|
continue
|
|
@@ -65,48 +99,36 @@ def reorder_migration_ops(context, revision, directives: list[MigrationScript]):
|
|
|
65
99
|
upgrade_ops = directive.upgrade_ops.ops
|
|
66
100
|
|
|
67
101
|
schema_ops = []
|
|
68
|
-
extension_ops = []
|
|
69
102
|
trigger_ops = []
|
|
70
103
|
other_ops = []
|
|
71
104
|
|
|
72
105
|
for op in upgrade_ops:
|
|
73
106
|
if isinstance(op, SchemaCreate):
|
|
74
107
|
schema_ops.append(op)
|
|
75
|
-
elif (
|
|
76
|
-
isinstance(op, CreateOp)
|
|
77
|
-
and getattr(op.target, "signature", None) == "btree_gist"
|
|
78
|
-
):
|
|
79
|
-
extension_ops.append(op)
|
|
80
108
|
elif isinstance(op, CreateOp):
|
|
81
109
|
trigger_ops.append(op)
|
|
82
110
|
else:
|
|
83
111
|
other_ops.append(op)
|
|
84
112
|
|
|
85
113
|
# Reassemble the ops sequence in strict dependency order
|
|
86
|
-
directive.upgrade_ops.ops = schema_ops +
|
|
114
|
+
directive.upgrade_ops.ops = schema_ops + other_ops + trigger_ops
|
|
87
115
|
|
|
88
116
|
if hasattr(directive, "downgrade_ops") and directive.downgrade_ops is not None:
|
|
89
117
|
downgrade_ops = directive.downgrade_ops.ops
|
|
90
118
|
d_schema_ops = []
|
|
91
|
-
d_extension_ops = []
|
|
92
119
|
d_trigger_ops = []
|
|
93
120
|
d_other_ops = []
|
|
94
121
|
for op in downgrade_ops:
|
|
95
122
|
target = getattr(op, "target", None)
|
|
96
123
|
if isinstance(op, SchemaDrop):
|
|
97
124
|
d_schema_ops.append(op)
|
|
98
|
-
elif (
|
|
99
|
-
target is not None
|
|
100
|
-
and getattr(target, "signature", None) == "btree_gist"
|
|
101
|
-
):
|
|
102
|
-
d_extension_ops.append(op)
|
|
103
125
|
elif target is not None:
|
|
104
126
|
d_trigger_ops.append(op)
|
|
105
127
|
else:
|
|
106
128
|
d_other_ops.append(op)
|
|
107
|
-
directive.downgrade_ops.ops =
|
|
108
|
-
|
|
109
|
-
|
|
129
|
+
directive.downgrade_ops.ops = d_trigger_ops + d_other_ops + d_schema_ops
|
|
130
|
+
|
|
131
|
+
restore_blind_entities()
|
|
110
132
|
|
|
111
133
|
|
|
112
134
|
def chain_revision_directives(*callbacks):
|
|
@@ -123,12 +145,12 @@ def chain_revision_directives(*callbacks):
|
|
|
123
145
|
|
|
124
146
|
|
|
125
147
|
def register_core_entities(audit: "PostgreSQLAudit"):
|
|
126
|
-
audit.pg_audit_entities.add(entities.btree_gist)
|
|
127
148
|
audit.pg_audit_entities.add(entities.get_pk_values(**audit.context))
|
|
128
149
|
audit.pg_audit_entities.add(entities.get_table_relid(**audit.context))
|
|
129
150
|
audit.pg_audit_entities.add(entities.get_setting_factory(**audit.context))
|
|
130
151
|
audit.pg_audit_entities.add(entities.jsonb_subtract_factory(**audit.context))
|
|
131
152
|
audit.pg_audit_entities.add(entities.jsonb_array_subtract_factory(**audit.context))
|
|
153
|
+
audit.pg_audit_entities.add(entities.jsonb_diff_factory(**audit.context))
|
|
132
154
|
audit.pg_audit_entities.add(entities.create_activity_factory(**audit.context))
|
|
133
155
|
|
|
134
156
|
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
import sqlalchemy as sa
|
|
4
|
+
from alembic.autogenerate import render, renderers
|
|
5
|
+
from alembic.autogenerate.api import AutogenContext
|
|
6
|
+
from alembic.operations import ops
|
|
7
|
+
from sqlalchemy.dialects import postgresql
|
|
8
|
+
from sqlalchemy.dialects.postgresql.base import PGDialect
|
|
9
|
+
|
|
10
|
+
from ..types import XID8
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class XID8AlterColumnOp(ops.AlterColumnOp):
|
|
14
|
+
"""Alter column operation that renders a PostgreSQL ``USING`` clause."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, *args: Any, postgresql_using: str, **kw: Any) -> None:
|
|
17
|
+
super().__init__(*args, **kw)
|
|
18
|
+
self.postgresql_using = postgresql_using
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@renderers.dispatch_for(XID8AlterColumnOp)
|
|
22
|
+
def render_xid8_alter_column(
|
|
23
|
+
autogen_context: AutogenContext, op: XID8AlterColumnOp
|
|
24
|
+
) -> str:
|
|
25
|
+
text = render._alter_column(autogen_context, op)
|
|
26
|
+
return text[:-1] + f",\n postgresql_using={op.postgresql_using!r})"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def register_xid8_type() -> None:
|
|
30
|
+
"""Register ``xid8`` with SQLAlchemy reflection and Alembic rendering."""
|
|
31
|
+
# Expose the type through the PostgreSQL dialect so Alembic renders it as
|
|
32
|
+
# ``postgresql.XID8()`` and imports the dialect in generated migrations.
|
|
33
|
+
XID8.__module__ = postgresql.__name__
|
|
34
|
+
if not hasattr(postgresql, "XID8"):
|
|
35
|
+
postgresql.XID8 = XID8 # ty: ignore[missing-attribute]
|
|
36
|
+
|
|
37
|
+
# Let SQLAlchemy reflect existing ``xid8`` columns with this type.
|
|
38
|
+
PGDialect.ischema_names.setdefault("xid8", XID8) # ty: ignore[no-matching-overload]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _xid8_using_clause(op: ops.AlterColumnOp) -> str | None:
|
|
42
|
+
if op.column_name != "native_transaction_id":
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
existing = op.existing_type
|
|
46
|
+
modify = op.modify_type
|
|
47
|
+
|
|
48
|
+
if isinstance(modify, XID8) and isinstance(existing, sa.BigInteger):
|
|
49
|
+
return f"{op.column_name}::text::xid8"
|
|
50
|
+
if isinstance(existing, XID8) and isinstance(modify, sa.BigInteger):
|
|
51
|
+
return f"{op.column_name}::text::bigint"
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _make_xid8_alter_column_op(op: ops.AlterColumnOp, using: str) -> XID8AlterColumnOp:
|
|
56
|
+
kw = dict(op.kw)
|
|
57
|
+
kw.pop("postgresql_using", None)
|
|
58
|
+
return XID8AlterColumnOp(
|
|
59
|
+
op.table_name,
|
|
60
|
+
op.column_name,
|
|
61
|
+
schema=op.schema,
|
|
62
|
+
existing_type=op.existing_type,
|
|
63
|
+
existing_server_default=op.existing_server_default,
|
|
64
|
+
existing_nullable=op.existing_nullable,
|
|
65
|
+
existing_comment=op.existing_comment,
|
|
66
|
+
modify_nullable=op.modify_nullable,
|
|
67
|
+
modify_comment=op.modify_comment,
|
|
68
|
+
modify_server_default=op.modify_server_default,
|
|
69
|
+
modify_name=op.modify_name,
|
|
70
|
+
modify_type=op.modify_type,
|
|
71
|
+
postgresql_using=using,
|
|
72
|
+
**kw,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _replace_xid8_ops(container: ops.OpContainer) -> None:
|
|
77
|
+
for index, op in enumerate(container.ops):
|
|
78
|
+
if isinstance(op, XID8AlterColumnOp):
|
|
79
|
+
continue
|
|
80
|
+
if isinstance(op, ops.AlterColumnOp):
|
|
81
|
+
if using := _xid8_using_clause(op):
|
|
82
|
+
container.ops[index] = _make_xid8_alter_column_op(op, using)
|
|
83
|
+
elif isinstance(op, ops.OpContainer):
|
|
84
|
+
_replace_xid8_ops(op)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def apply_xid8_type_changes(directives: list[Any]) -> None:
|
|
88
|
+
"""Add ``USING`` clauses for ``native_transaction_id`` type migrations."""
|
|
89
|
+
for directive in directives:
|
|
90
|
+
for container in (
|
|
91
|
+
getattr(directive, "upgrade_ops", None),
|
|
92
|
+
getattr(directive, "downgrade_ops", None),
|
|
93
|
+
):
|
|
94
|
+
if container is not None:
|
|
95
|
+
_replace_xid8_ops(container)
|
{flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/base.py
RENAMED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
from datetime import datetime
|
|
2
2
|
from typing import TYPE_CHECKING, Any
|
|
3
3
|
|
|
4
|
-
from sqlalchemy import BigInteger, Text
|
|
4
|
+
from sqlalchemy import BigInteger, Text
|
|
5
5
|
from sqlalchemy.dialects.postgresql import INET, JSONB
|
|
6
6
|
from sqlalchemy.orm import Mapped, declared_attr, mapped_column
|
|
7
7
|
|
|
8
|
+
from .types import XID8
|
|
8
9
|
from .typing import OMap
|
|
9
10
|
|
|
10
11
|
|
|
@@ -19,7 +20,7 @@ class ActivityBase:
|
|
|
19
20
|
|
|
20
21
|
relid: OMap[int]
|
|
21
22
|
issued_at: OMap[datetime]
|
|
22
|
-
native_transaction_id: OMap[int] = mapped_column(
|
|
23
|
+
native_transaction_id: OMap[int] = mapped_column(XID8, index=True)
|
|
23
24
|
verb: OMap[str] = mapped_column(Text)
|
|
24
25
|
|
|
25
26
|
row_key: OMap[list[str]] = mapped_column(JSONB, default=[], server_default="[]")
|
|
@@ -37,15 +38,10 @@ class TransactionBase:
|
|
|
37
38
|
actor_id: declared_attr[Any]
|
|
38
39
|
|
|
39
40
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
|
40
|
-
native_transaction_id: OMap[int] = mapped_column(
|
|
41
|
+
native_transaction_id: OMap[int] = mapped_column(XID8)
|
|
41
42
|
issued_at: OMap[datetime]
|
|
42
43
|
client_addr: OMap[str] = mapped_column(INET)
|
|
43
44
|
|
|
44
|
-
@classmethod
|
|
45
|
-
def __transaction_interval__(cls):
|
|
46
|
-
interval = text("INTERVAL '1 hour'")
|
|
47
|
-
return func.tsrange(cls.issued_at - interval, cls.issued_at)
|
|
48
|
-
|
|
49
45
|
def __repr__(self):
|
|
50
46
|
return (
|
|
51
47
|
f"<{self.__class__.__name__} id={self.id!r} issued_at={self.issued_at!r}>"
|
{flask_postgresql_audit-1.1.2 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/core.py
RENAMED
|
@@ -134,10 +134,12 @@ def _default_actor_id() -> int | str | None:
|
|
|
134
134
|
|
|
135
135
|
def _default_client_addr() -> str | None:
|
|
136
136
|
try:
|
|
137
|
-
from flask import request
|
|
137
|
+
from flask import has_request_context, request
|
|
138
138
|
except ImportError:
|
|
139
139
|
return None
|
|
140
|
-
|
|
140
|
+
if not has_request_context():
|
|
141
|
+
return None
|
|
142
|
+
return request.remote_addr
|
|
141
143
|
|
|
142
144
|
|
|
143
145
|
class PostgreSQLAudit:
|
|
@@ -219,7 +221,7 @@ class PostgreSQLAudit:
|
|
|
219
221
|
def insert_transaction(self, session: orm.Session):
|
|
220
222
|
if self.pg_audit_enabled:
|
|
221
223
|
values = {
|
|
222
|
-
"native_transaction_id": sa.func.
|
|
224
|
+
"native_transaction_id": sa.func.pg_current_xact_id(),
|
|
223
225
|
"issued_at": sa.text("NOW() AT TIME ZONE 'UTC'"),
|
|
224
226
|
"client_addr": self.get_client_addr(),
|
|
225
227
|
"actor_id": self.get_actor_id(),
|
|
@@ -314,7 +316,7 @@ class PostgreSQLAudit:
|
|
|
314
316
|
)
|
|
315
317
|
|
|
316
318
|
if inspect.isclass(obj):
|
|
317
|
-
class_ = obj
|
|
319
|
+
class_ = t.cast(type[Audit], obj)
|
|
318
320
|
stmt = stmt.filter(self.Activity.table_name == class_.__tablename__)
|
|
319
321
|
|
|
320
322
|
else:
|