flask-postgresql-audit 1.1.3__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.3 → flask_postgresql_audit-1.2.0}/CHANGES.md +16 -0
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/PKG-INFO +66 -4
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/README.md +60 -2
- {flask_postgresql_audit-1.1.3 → 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.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/alembic/entities.py +0 -3
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/alembic/setup.py +40 -19
- flask_postgresql_audit-1.2.0/flask_postgresql_audit/alembic/xid8.py +95 -0
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/base.py +4 -8
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/core.py +6 -4
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/extensions/document_staging/model.py +2 -2
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/models.py +5 -7
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/templates/create_activity.sql +4 -4
- flask_postgresql_audit-1.2.0/flask_postgresql_audit/types.py +29 -0
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/pyproject.toml +10 -0
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/tests/app.py +7 -5
- {flask_postgresql_audit-1.1.3 → 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.3 → flask_postgresql_audit-1.2.0}/tests/test_defaults.py +10 -1
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/tests/utils.py +0 -1
- flask_postgresql_audit-1.1.3/flask_postgresql_audit/alembic/__init__.py +0 -7
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/.gitignore +0 -0
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/LICENSE +0 -0
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/alembic/schema.py +0 -0
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/extensions/__init__.py +0 -0
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/extensions/alembic_utils/__init__.py +0 -0
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/extensions/alembic_utils/pg_aggregate.py +0 -0
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/extensions/document_staging/__init__.py +0 -0
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/extensions/document_staging/enum.py +0 -0
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/extensions/document_staging/listener.py +0 -0
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/templates/audit_trigger_delete.sql +0 -0
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/templates/audit_trigger_insert.sql +0 -0
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/templates/audit_trigger_update.sql +0 -0
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/templates/get_pk_values.sql +0 -0
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/templates/get_setting.sql +0 -0
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/templates/get_table_relid.sql +0 -0
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/templates/jsonb_array_subtract.sql +0 -0
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/templates/jsonb_diff.sql +0 -0
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/templates/jsonb_subtract.sql +0 -0
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/typing.py +0 -0
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/utils.py +0 -0
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/tests/__init__.py +0 -0
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/tests/migrations/README +0 -0
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/tests/migrations/alembic.ini +0 -0
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/tests/migrations/env.py +0 -0
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/tests/migrations/script.py.mako +0 -0
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/tests/test_document_staging.py +0 -0
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/tests/test_operators.py +0 -0
- {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/tests/test_pg_aggregate.py +0 -0
|
@@ -3,6 +3,22 @@
|
|
|
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
|
+
|
|
6
22
|
## 1.1.3 (2026-08-21)
|
|
7
23
|
|
|
8
24
|
- Add `jsonb_diff()` function to recursively compute differences across JSONB objects and 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)
|
|
@@ -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,7 +145,6 @@ 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))
|
|
@@ -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.3 → 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.3 → 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:
|
|
@@ -41,8 +41,8 @@ class DocumentStaging:
|
|
|
41
41
|
if not getattr(class_, "__staging_configured__", False):
|
|
42
42
|
table = class_.__table__
|
|
43
43
|
|
|
44
|
-
pk_cols = []
|
|
45
|
-
fk_cols = []
|
|
44
|
+
pk_cols: list[sa.ColumnClause[t.Any]] = []
|
|
45
|
+
fk_cols: list[sa.ColumnClause[t.Any]] = []
|
|
46
46
|
|
|
47
47
|
for pk in table.primary_key.columns:
|
|
48
48
|
fk_name = f"revision_{pk.name}"
|
{flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/models.py
RENAMED
|
@@ -2,7 +2,6 @@ import typing as t
|
|
|
2
2
|
|
|
3
3
|
import sqlalchemy as sa
|
|
4
4
|
from sqlalchemy import ForeignKey
|
|
5
|
-
from sqlalchemy.dialects.postgresql import ExcludeConstraint
|
|
6
5
|
from sqlalchemy.orm import declared_attr, mapped_column, relationship
|
|
7
6
|
|
|
8
7
|
from .base import ActivityBase, TransactionBase
|
|
@@ -26,7 +25,7 @@ def activity_model_factory(
|
|
|
26
25
|
schema_name: str | None = None,
|
|
27
26
|
**kwargs,
|
|
28
27
|
):
|
|
29
|
-
class PGAuditActivity(base_cls, activity_base):
|
|
28
|
+
class PGAuditActivity(base_cls, activity_base): # ty: ignore[unsupported-base]
|
|
30
29
|
__tablename__ = "pga_activity"
|
|
31
30
|
__table_args__: t.ClassVar[dict[str, t.Any]] = {"schema": schema_name}
|
|
32
31
|
|
|
@@ -53,13 +52,13 @@ def transaction_model_factory(
|
|
|
53
52
|
|
|
54
53
|
actor_pk = actor_mapper.primary_key[0]
|
|
55
54
|
|
|
56
|
-
class PGAuditTransaction(base_cls, transaction_base):
|
|
55
|
+
class PGAuditTransaction(base_cls, transaction_base): # ty: ignore[unsupported-base]
|
|
57
56
|
__tablename__ = "pga_transaction"
|
|
58
57
|
|
|
59
58
|
@declared_attr
|
|
60
59
|
def actor_id(cls) -> OMap[t.Any]:
|
|
61
60
|
if actor_cls:
|
|
62
|
-
return mapped_column(actor_pk.type, ForeignKey(actor_pk))
|
|
61
|
+
return mapped_column(actor_pk.type, ForeignKey(actor_pk))
|
|
63
62
|
return mapped_column(sa.Text)
|
|
64
63
|
|
|
65
64
|
if actor_cls:
|
|
@@ -69,9 +68,8 @@ def transaction_model_factory(
|
|
|
69
68
|
@classmethod
|
|
70
69
|
def __table_args__(cls):
|
|
71
70
|
return (
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
(cls.__transaction_interval__(), "&&"),
|
|
71
|
+
sa.UniqueConstraint(
|
|
72
|
+
cls.native_transaction_id,
|
|
75
73
|
name="pga_transaction_unique_native_tx_id",
|
|
76
74
|
),
|
|
77
75
|
{"schema": schema_name},
|
|
@@ -7,7 +7,7 @@ BEGIN
|
|
|
7
7
|
_transaction_id := (
|
|
8
8
|
SELECT id
|
|
9
9
|
FROM ${schema_prefix}pga_transaction
|
|
10
|
-
WHERE native_transaction_id =
|
|
10
|
+
WHERE native_transaction_id = pg_current_xact_id()
|
|
11
11
|
AND issued_at >= (NOW() - INTERVAL '1 day')
|
|
12
12
|
ORDER BY issued_at DESC
|
|
13
13
|
LIMIT 1
|
|
@@ -27,7 +27,7 @@ BEGIN
|
|
|
27
27
|
TG_TABLE_NAME::text AS table_name,
|
|
28
28
|
TG_RELID AS relid,
|
|
29
29
|
statement_timestamp() AT TIME ZONE 'UTC' AS issued_at,
|
|
30
|
-
|
|
30
|
+
pg_current_xact_id() AS native_transaction_id,
|
|
31
31
|
LOWER(TG_OP) AS verb,
|
|
32
32
|
${schema_prefix}get_pk_values(TG_RELID, new_data) AS row_key,
|
|
33
33
|
old_data - excluded_cols AS old_data,
|
|
@@ -56,7 +56,7 @@ BEGIN
|
|
|
56
56
|
TG_TABLE_NAME::text AS table_name,
|
|
57
57
|
TG_RELID AS relid,
|
|
58
58
|
statement_timestamp() AT TIME ZONE 'UTC' AS issued_at,
|
|
59
|
-
|
|
59
|
+
pg_current_xact_id() AS native_transaction_id,
|
|
60
60
|
LOWER(TG_OP) AS verb,
|
|
61
61
|
${schema_prefix}get_pk_values(TG_RELID, to_jsonb(new_table)) AS row_key,
|
|
62
62
|
'{}'::jsonb AS old_data,
|
|
@@ -73,7 +73,7 @@ BEGIN
|
|
|
73
73
|
TG_TABLE_NAME::text AS table_name,
|
|
74
74
|
TG_RELID AS relid,
|
|
75
75
|
statement_timestamp() AT TIME ZONE 'UTC' AS issued_at,
|
|
76
|
-
|
|
76
|
+
pg_current_xact_id() AS native_transaction_id,
|
|
77
77
|
LOWER(TG_OP) AS verb,
|
|
78
78
|
${schema_prefix}get_pk_values(TG_RELID, to_jsonb(old_table)) AS row_key,
|
|
79
79
|
row_to_json(old_table.*)::jsonb - excluded_cols AS old_data,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
import sqlalchemy as sa
|
|
4
|
+
from sqlalchemy.engine import Dialect
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class XID8(sa.types.UserDefinedType[int]):
|
|
8
|
+
"""PostgreSQL 64-bit transaction identifier (``xid8``)."""
|
|
9
|
+
|
|
10
|
+
cache_ok = True
|
|
11
|
+
|
|
12
|
+
def get_col_spec(self, **kw: Any) -> str:
|
|
13
|
+
return "xid8"
|
|
14
|
+
|
|
15
|
+
def bind_processor(self, dialect: Dialect):
|
|
16
|
+
def process(value: int | None) -> str | None:
|
|
17
|
+
return str(value) if value is not None else None
|
|
18
|
+
|
|
19
|
+
return process
|
|
20
|
+
|
|
21
|
+
def result_processor(self, dialect: Dialect, coltype: Any):
|
|
22
|
+
def process(value: str | int | None) -> int | None:
|
|
23
|
+
return int(value) if value is not None else None
|
|
24
|
+
|
|
25
|
+
return process
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def python_type(self) -> type[int]:
|
|
29
|
+
return int
|
|
@@ -21,6 +21,8 @@ classifiers = [
|
|
|
21
21
|
"Programming Language :: Python :: 3.11",
|
|
22
22
|
"Programming Language :: Python :: 3.12",
|
|
23
23
|
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Programming Language :: Python :: 3.14",
|
|
25
|
+
"Programming Language :: Python :: 3.15",
|
|
24
26
|
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
|
25
27
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
26
28
|
]
|
|
@@ -29,6 +31,11 @@ dependencies = [
|
|
|
29
31
|
"alembic-utils>=0.8.8",
|
|
30
32
|
"flask>=3.1.2",
|
|
31
33
|
"flask-sqlalchemy>=3.1.1",
|
|
34
|
+
"psycopg[binary]>=3.2",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
psycopg2 = [
|
|
32
39
|
"psycopg2>=2.9.11",
|
|
33
40
|
]
|
|
34
41
|
|
|
@@ -37,10 +44,13 @@ Homepage = "https://github.com/higamigu/flask-postgresql-audit"
|
|
|
37
44
|
|
|
38
45
|
[dependency-groups]
|
|
39
46
|
dev = [
|
|
47
|
+
"pre-commit>=4.7.0",
|
|
48
|
+
"psycopg2-binary>=2.9.11",
|
|
40
49
|
"pytest>=8.4.2",
|
|
41
50
|
"ruff>=0.16.1",
|
|
42
51
|
"setuptools>=80.9.0",
|
|
43
52
|
"tox>=4.31.0",
|
|
53
|
+
"ty>=0.0.101",
|
|
44
54
|
]
|
|
45
55
|
|
|
46
56
|
[tool.hatch.version]
|
|
@@ -20,20 +20,22 @@ class DefaultConfig:
|
|
|
20
20
|
DB_PASSWORD = os.environ.get("POSTGRES_PASSWORD", "postgres")
|
|
21
21
|
DB_NAME = os.environ.get("POSTGRES_DB", "pg_audit_test")
|
|
22
22
|
DB_PORT = os.environ.get("POSTGRES_PORT", "5432")
|
|
23
|
+
DB_DRIVER = os.environ.get("POSTGRES_DRIVER", "psycopg")
|
|
23
24
|
|
|
24
|
-
SQLALCHEMY_DATABASE_URI =
|
|
25
|
-
f"postgresql://{DB_USER}:{DB_PASSWORD}@localhost:{DB_PORT}/{DB_NAME}"
|
|
26
|
-
)
|
|
25
|
+
SQLALCHEMY_DATABASE_URI = f"postgresql+{DB_DRIVER}://{DB_USER}:{DB_PASSWORD}@localhost:{DB_PORT}/{DB_NAME}"
|
|
27
26
|
SQLALCHEMY_ECHO = False
|
|
28
27
|
|
|
29
|
-
|
|
28
|
+
# An empty SCHEMA_NAME means "use the default schema", not a schema
|
|
29
|
+
# literally named "". Passing "" down to SQLAlchemy puts the audit tables
|
|
30
|
+
# in an "" schema, which autogenerate cannot match against reflection.
|
|
31
|
+
PGA_SCHEMA_NAME = os.environ.get("SCHEMA_NAME") or None
|
|
30
32
|
|
|
31
33
|
|
|
32
34
|
db = SQLAlchemy()
|
|
33
35
|
audit = PostgreSQLAudit(schema_name=DefaultConfig.PGA_SCHEMA_NAME)
|
|
34
36
|
|
|
35
37
|
|
|
36
|
-
class BaseModel(db.Model):
|
|
38
|
+
class BaseModel(db.Model): # ty: ignore[unsupported-base]
|
|
37
39
|
__abstract__: bool = True
|
|
38
40
|
|
|
39
41
|
if TYPE_CHECKING:
|
|
@@ -1,10 +1,20 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
1
4
|
import pytest
|
|
2
5
|
|
|
3
6
|
from tests.utils import REPO_ROOT, alembic_migration, test_session
|
|
4
7
|
|
|
5
8
|
from .app import Article, User, app, db
|
|
6
9
|
|
|
7
|
-
ALEMBIC_CONFIG =
|
|
10
|
+
ALEMBIC_CONFIG = Path(
|
|
11
|
+
os.environ.get("ALEMBIC_CONFIG", str(REPO_ROOT / "tests/migrations"))
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture(scope="session")
|
|
16
|
+
def alembic_config_path():
|
|
17
|
+
return ALEMBIC_CONFIG
|
|
8
18
|
|
|
9
19
|
|
|
10
20
|
@pytest.fixture(scope="session")
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from types import SimpleNamespace
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
import sqlalchemy as sa
|
|
7
|
+
from alembic.autogenerate import compare_metadata, render
|
|
8
|
+
from alembic.autogenerate.api import AutogenContext
|
|
9
|
+
from alembic.migration import MigrationContext
|
|
10
|
+
from alembic.operations import ops
|
|
11
|
+
|
|
12
|
+
from flask_postgresql_audit.alembic import apply_xid8_type_changes
|
|
13
|
+
from flask_postgresql_audit.alembic.xid8 import XID8AlterColumnOp
|
|
14
|
+
from flask_postgresql_audit.types import XID8
|
|
15
|
+
|
|
16
|
+
from .app import audit, db
|
|
17
|
+
from .utils import downgrade, revision, upgrade
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.mark.usefixtures("test_client")
|
|
21
|
+
class TestXID8AlembicSupport:
|
|
22
|
+
def test_columns_reflect_as_xid8(self):
|
|
23
|
+
inspector = sa.inspect(db.engine)
|
|
24
|
+
|
|
25
|
+
schema = audit.schema_name or None
|
|
26
|
+
for table_name in ("pga_activity", "pga_transaction"):
|
|
27
|
+
columns = {
|
|
28
|
+
column["name"]: column["type"]
|
|
29
|
+
for column in inspector.get_columns(table_name, schema=schema)
|
|
30
|
+
}
|
|
31
|
+
assert isinstance(columns["native_transaction_id"], XID8)
|
|
32
|
+
|
|
33
|
+
def test_autogenerate_does_not_change_xid8_columns(self):
|
|
34
|
+
with db.engine.connect() as connection:
|
|
35
|
+
context = MigrationContext.configure(
|
|
36
|
+
connection, opts={"include_schemas": True}
|
|
37
|
+
)
|
|
38
|
+
diffs = compare_metadata(context, db.metadata)
|
|
39
|
+
|
|
40
|
+
assert not any(
|
|
41
|
+
isinstance(diff, tuple)
|
|
42
|
+
and diff[0] == "modify_type"
|
|
43
|
+
and diff[3] == "native_transaction_id"
|
|
44
|
+
for diff in diffs
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def test_adds_using_clause_when_upgrading_to_xid8(self):
|
|
48
|
+
alter = ops.AlterColumnOp(
|
|
49
|
+
"pga_activity",
|
|
50
|
+
"native_transaction_id",
|
|
51
|
+
existing_type=sa.BigInteger(),
|
|
52
|
+
modify_type=XID8(),
|
|
53
|
+
)
|
|
54
|
+
directive = SimpleNamespace(
|
|
55
|
+
upgrade_ops=ops.UpgradeOps(ops=[alter]),
|
|
56
|
+
downgrade_ops=None,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
apply_xid8_type_changes([directive])
|
|
60
|
+
|
|
61
|
+
result = directive.upgrade_ops.ops[0]
|
|
62
|
+
assert isinstance(result, XID8AlterColumnOp)
|
|
63
|
+
assert result.postgresql_using == "native_transaction_id::text::xid8"
|
|
64
|
+
|
|
65
|
+
def test_adds_using_clause_when_downgrading_to_bigint(self):
|
|
66
|
+
alter = ops.AlterColumnOp(
|
|
67
|
+
"pga_activity",
|
|
68
|
+
"native_transaction_id",
|
|
69
|
+
existing_type=XID8(),
|
|
70
|
+
modify_type=sa.BigInteger(),
|
|
71
|
+
)
|
|
72
|
+
directive = SimpleNamespace(
|
|
73
|
+
upgrade_ops=ops.UpgradeOps(ops=[]),
|
|
74
|
+
downgrade_ops=ops.DowngradeOps(ops=[alter]),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
apply_xid8_type_changes([directive])
|
|
78
|
+
|
|
79
|
+
result = directive.downgrade_ops.ops[0]
|
|
80
|
+
assert isinstance(result, XID8AlterColumnOp)
|
|
81
|
+
assert result.postgresql_using == "native_transaction_id::text::bigint"
|
|
82
|
+
|
|
83
|
+
def test_renders_using_clause(self):
|
|
84
|
+
alter = XID8AlterColumnOp(
|
|
85
|
+
"pga_activity",
|
|
86
|
+
"native_transaction_id",
|
|
87
|
+
existing_type=sa.BigInteger(),
|
|
88
|
+
modify_type=XID8(),
|
|
89
|
+
postgresql_using="native_transaction_id::text::xid8",
|
|
90
|
+
)
|
|
91
|
+
context = AutogenContext(
|
|
92
|
+
MigrationContext.configure(dialect_name="postgresql"),
|
|
93
|
+
opts={
|
|
94
|
+
"alembic_module_prefix": "op.",
|
|
95
|
+
"sqlalchemy_module_prefix": "sa.",
|
|
96
|
+
"user_module_prefix": None,
|
|
97
|
+
"include_schemas": True,
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
rendered = render.render_op_text(context, alter)
|
|
102
|
+
|
|
103
|
+
assert "postgresql_using='native_transaction_id::text::xid8'" in rendered
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@pytest.mark.usefixtures("test_client")
|
|
107
|
+
class TestXID8LegacyMigrationGeneration:
|
|
108
|
+
"""Autogenerate must produce a runnable migration for a 1.0.x database."""
|
|
109
|
+
|
|
110
|
+
def _revert_to_legacy_layout(self):
|
|
111
|
+
schema = audit.schema_name or "public"
|
|
112
|
+
prefix = "" if schema == "public" else f"{schema}."
|
|
113
|
+
|
|
114
|
+
statements = [
|
|
115
|
+
"CREATE EXTENSION IF NOT EXISTS btree_gist",
|
|
116
|
+
(
|
|
117
|
+
f"ALTER TABLE {prefix}pga_transaction"
|
|
118
|
+
" DROP CONSTRAINT pga_transaction_unique_native_tx_id"
|
|
119
|
+
),
|
|
120
|
+
(
|
|
121
|
+
f"ALTER TABLE {prefix}pga_transaction"
|
|
122
|
+
" ALTER COLUMN native_transaction_id TYPE bigint"
|
|
123
|
+
" USING native_transaction_id::text::bigint"
|
|
124
|
+
),
|
|
125
|
+
(
|
|
126
|
+
f"ALTER TABLE {prefix}pga_activity"
|
|
127
|
+
" ALTER COLUMN native_transaction_id TYPE bigint"
|
|
128
|
+
" USING native_transaction_id::text::bigint"
|
|
129
|
+
),
|
|
130
|
+
(
|
|
131
|
+
f"ALTER TABLE {prefix}pga_transaction"
|
|
132
|
+
" ADD CONSTRAINT pga_transaction_unique_native_tx_id"
|
|
133
|
+
" EXCLUDE USING gist ("
|
|
134
|
+
" native_transaction_id WITH =,"
|
|
135
|
+
" tsrange(issued_at - INTERVAL '1 hour', issued_at) WITH &&)"
|
|
136
|
+
),
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
with db.engine.begin() as connection:
|
|
140
|
+
for statement in statements:
|
|
141
|
+
connection.execute(sa.text(statement))
|
|
142
|
+
|
|
143
|
+
def _native_transaction_columns(self):
|
|
144
|
+
inspector = sa.inspect(db.engine)
|
|
145
|
+
schema = audit.schema_name or None
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
table_name: {
|
|
149
|
+
column["name"]: column["type"]
|
|
150
|
+
for column in inspector.get_columns(table_name, schema=schema)
|
|
151
|
+
}["native_transaction_id"]
|
|
152
|
+
for table_name in ("pga_activity", "pga_transaction")
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
def _native_transaction_constraint(self):
|
|
156
|
+
schema = audit.schema_name or "public"
|
|
157
|
+
query = sa.text(
|
|
158
|
+
"""
|
|
159
|
+
SELECT c.contype
|
|
160
|
+
FROM pg_constraint AS c
|
|
161
|
+
JOIN pg_class AS t ON t.oid = c.conrelid
|
|
162
|
+
JOIN pg_namespace AS n ON n.oid = t.relnamespace
|
|
163
|
+
WHERE t.relname = 'pga_transaction'
|
|
164
|
+
AND n.nspname = :schema
|
|
165
|
+
AND c.conname = 'pga_transaction_unique_native_tx_id'
|
|
166
|
+
"""
|
|
167
|
+
)
|
|
168
|
+
with db.engine.connect() as connection:
|
|
169
|
+
return connection.scalar(query, {"schema": schema})
|
|
170
|
+
|
|
171
|
+
def test_autogenerated_migration_swaps_legacy_constraint(self, alembic_config_path):
|
|
172
|
+
self._revert_to_legacy_layout()
|
|
173
|
+
|
|
174
|
+
revision(
|
|
175
|
+
db.engine,
|
|
176
|
+
alembic_config_path,
|
|
177
|
+
autogenerate=True,
|
|
178
|
+
rev_id="2",
|
|
179
|
+
message="xid8",
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
generated = next((alembic_config_path / "versions").glob("2_*.py")).read_text()
|
|
183
|
+
|
|
184
|
+
drop_constraint = (
|
|
185
|
+
"ALTER TABLE public.pga_transaction"
|
|
186
|
+
" DROP CONSTRAINT pga_transaction_unique_native_tx_id"
|
|
187
|
+
)
|
|
188
|
+
if audit.schema_name:
|
|
189
|
+
drop_constraint = drop_constraint.replace(
|
|
190
|
+
"public.", f"{audit.schema_name}."
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
assert drop_constraint in generated
|
|
194
|
+
assert (
|
|
195
|
+
generated.count("postgresql_using='native_transaction_id::text::xid8'") == 2
|
|
196
|
+
)
|
|
197
|
+
assert generated.index(drop_constraint) < generated.index(
|
|
198
|
+
"create_unique_constraint('pga_transaction_unique_native_tx_id'"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
upgrade(db.engine, alembic_config_path, revision="head")
|
|
202
|
+
|
|
203
|
+
assert all(
|
|
204
|
+
isinstance(type_, XID8)
|
|
205
|
+
for type_ in self._native_transaction_columns().values()
|
|
206
|
+
)
|
|
207
|
+
assert self._native_transaction_constraint() == "u"
|
|
208
|
+
|
|
209
|
+
downgrade(db.engine, alembic_config_path, revision="1")
|
|
210
|
+
|
|
211
|
+
assert all(
|
|
212
|
+
isinstance(type_, sa.BigInteger)
|
|
213
|
+
for type_ in self._native_transaction_columns().values()
|
|
214
|
+
)
|
|
215
|
+
assert self._native_transaction_constraint() == "x"
|
|
216
|
+
|
|
217
|
+
# Leave the database consistent with the model metadata again.
|
|
218
|
+
upgrade(db.engine, alembic_config_path, revision="head")
|
|
219
|
+
|
|
220
|
+
assert all(
|
|
221
|
+
isinstance(type_, XID8)
|
|
222
|
+
for type_ in self._native_transaction_columns().values()
|
|
223
|
+
)
|
|
224
|
+
assert self._native_transaction_constraint() == "u"
|
|
@@ -13,9 +13,18 @@ class TestCustomSchemaActivityCreation:
|
|
|
13
13
|
assert activity.old_data == {}
|
|
14
14
|
assert activity.changed_data == {"id": user.id, "name": "John", "age": 15}
|
|
15
15
|
assert activity.table_name == "user"
|
|
16
|
-
assert activity.native_transaction_id > 0
|
|
16
|
+
assert activity.native_transaction_id > 0
|
|
17
17
|
assert activity.verb == "insert"
|
|
18
18
|
|
|
19
|
+
def test_single_transaction_row_per_db_transaction(self):
|
|
20
|
+
db.session.add(User(name="Jack"))
|
|
21
|
+
db.session.flush()
|
|
22
|
+
db.session.add(User(name="John"))
|
|
23
|
+
db.session.flush()
|
|
24
|
+
|
|
25
|
+
stmt = sa.select(sa.func.count()).select_from(audit.Transaction)
|
|
26
|
+
assert db.session.scalar(stmt) == 1
|
|
27
|
+
|
|
19
28
|
def test_activity_after_commit(self):
|
|
20
29
|
user = User(name="Jack")
|
|
21
30
|
db.session.add(user)
|
|
@@ -82,7 +82,6 @@ def downgrade(engine: Engine, alembic_cfg_path: Path, **kw):
|
|
|
82
82
|
|
|
83
83
|
def clear_migrations(engine: Engine, alembic_cfg_path: Path, **kw):
|
|
84
84
|
with engine.begin() as connection:
|
|
85
|
-
connection.execute(text("DROP EXTENSION IF EXISTS btree_gist CASCADE;"))
|
|
86
85
|
connection.execute(text("DROP SCHEMA IF EXISTS public CASCADE;"))
|
|
87
86
|
connection.execute(text("DROP SCHEMA IF EXISTS audit CASCADE;"))
|
|
88
87
|
connection.execute(text("CREATE SCHEMA public;"))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/typing.py
RENAMED
|
File without changes
|
{flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/utils.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/tests/migrations/script.py.mako
RENAMED
|
File without changes
|
{flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/tests/test_document_staging.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|