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.
Files changed (50) hide show
  1. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/CHANGES.md +16 -0
  2. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/PKG-INFO +66 -4
  3. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/README.md +60 -2
  4. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/__init__.py +1 -1
  5. flask_postgresql_audit-1.2.0/flask_postgresql_audit/alembic/__init__.py +9 -0
  6. flask_postgresql_audit-1.2.0/flask_postgresql_audit/alembic/constraint.py +105 -0
  7. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/alembic/entities.py +0 -3
  8. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/alembic/setup.py +40 -19
  9. flask_postgresql_audit-1.2.0/flask_postgresql_audit/alembic/xid8.py +95 -0
  10. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/base.py +4 -8
  11. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/core.py +6 -4
  12. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/extensions/document_staging/model.py +2 -2
  13. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/models.py +5 -7
  14. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/templates/create_activity.sql +4 -4
  15. flask_postgresql_audit-1.2.0/flask_postgresql_audit/types.py +29 -0
  16. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/pyproject.toml +10 -0
  17. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/tests/app.py +7 -5
  18. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/tests/conftest.py +11 -1
  19. flask_postgresql_audit-1.2.0/tests/test_alembic_xid8.py +224 -0
  20. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/tests/test_defaults.py +10 -1
  21. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/tests/utils.py +0 -1
  22. flask_postgresql_audit-1.1.3/flask_postgresql_audit/alembic/__init__.py +0 -7
  23. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/.gitignore +0 -0
  24. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/LICENSE +0 -0
  25. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/alembic/schema.py +0 -0
  26. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/extensions/__init__.py +0 -0
  27. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/extensions/alembic_utils/__init__.py +0 -0
  28. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/extensions/alembic_utils/pg_aggregate.py +0 -0
  29. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/extensions/document_staging/__init__.py +0 -0
  30. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/extensions/document_staging/enum.py +0 -0
  31. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/extensions/document_staging/listener.py +0 -0
  32. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/templates/audit_trigger_delete.sql +0 -0
  33. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/templates/audit_trigger_insert.sql +0 -0
  34. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/templates/audit_trigger_update.sql +0 -0
  35. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/templates/get_pk_values.sql +0 -0
  36. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/templates/get_setting.sql +0 -0
  37. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/templates/get_table_relid.sql +0 -0
  38. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/templates/jsonb_array_subtract.sql +0 -0
  39. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/templates/jsonb_diff.sql +0 -0
  40. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/templates/jsonb_subtract.sql +0 -0
  41. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/typing.py +0 -0
  42. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/flask_postgresql_audit/utils.py +0 -0
  43. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/tests/__init__.py +0 -0
  44. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/tests/migrations/README +0 -0
  45. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/tests/migrations/alembic.ini +0 -0
  46. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/tests/migrations/env.py +0 -0
  47. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/tests/migrations/script.py.mako +0 -0
  48. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/tests/test_document_staging.py +0 -0
  49. {flask_postgresql_audit-1.1.3 → flask_postgresql_audit-1.2.0}/tests/test_operators.py +0 -0
  50. {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.1.3
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: psycopg2>=2.9.11
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
- createdb postgresql_audit_test
326
- tox
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
- createdb postgresql_audit_test
299
- tox
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
@@ -1,4 +1,4 @@
1
1
  # ruff: noqa: F401
2
2
  from .core import Audit, PostgreSQLAudit
3
3
 
4
- __version__ = "1.1.3"
4
+ __version__ = "1.2.0"
@@ -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
- registry._entities.pop(ent.identity, None)
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 & btree_gist extension
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 + extension_ops + other_ops + trigger_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
- d_trigger_ops + d_other_ops + d_extension_ops + d_schema_ops
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)
@@ -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, func, 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(BigInteger, index=True)
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(BigInteger)
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}>"
@@ -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
- return (request and request.remote_addr) or None
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.txid_current(),
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}"
@@ -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)) # type: ignore
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
- ExcludeConstraint(
73
- (cls.native_transaction_id, "="),
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 = txid_current()
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
- txid_current() AS native_transaction_id,
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
- txid_current() AS native_transaction_id,
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
- txid_current() AS native_transaction_id,
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
- PGA_SCHEMA_NAME = os.environ.get("SCHEMA_NAME", None)
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 = REPO_ROOT / "tests/migrations"
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 # type: ignore
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;"))
@@ -1,7 +0,0 @@
1
- # ruff: noqa: F401
2
- from .setup import (
3
- chain_revision_directives,
4
- register_triggers,
5
- reorder_migration_ops,
6
- setup_db,
7
- )