flask-postgresql-audit 1.1.1__tar.gz → 1.1.2__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 (44) hide show
  1. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/.gitignore +1 -0
  2. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/CHANGES.md +6 -0
  3. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/PKG-INFO +25 -2
  4. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/README.md +23 -0
  5. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/flask_postgresql_audit/__init__.py +1 -1
  6. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/flask_postgresql_audit/alembic/entities.py +9 -0
  7. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/flask_postgresql_audit/alembic/setup.py +1 -0
  8. flask_postgresql_audit-1.1.2/flask_postgresql_audit/templates/jsonb_array_subtract.sql +29 -0
  9. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/tests/app.py +12 -0
  10. flask_postgresql_audit-1.1.2/tests/test_document_staging.py +106 -0
  11. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/tests/test_operators.py +32 -0
  12. flask_postgresql_audit-1.1.2/tests/test_pg_aggregate.py +96 -0
  13. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/LICENSE +0 -0
  14. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/flask_postgresql_audit/alembic/__init__.py +0 -0
  15. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/flask_postgresql_audit/alembic/schema.py +0 -0
  16. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/flask_postgresql_audit/base.py +0 -0
  17. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/flask_postgresql_audit/core.py +0 -0
  18. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/flask_postgresql_audit/extensions/__init__.py +0 -0
  19. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/flask_postgresql_audit/extensions/alembic_utils/__init__.py +0 -0
  20. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/flask_postgresql_audit/extensions/alembic_utils/pg_aggregate.py +0 -0
  21. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/flask_postgresql_audit/extensions/document_staging/__init__.py +0 -0
  22. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/flask_postgresql_audit/extensions/document_staging/enum.py +0 -0
  23. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/flask_postgresql_audit/extensions/document_staging/listener.py +0 -0
  24. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/flask_postgresql_audit/extensions/document_staging/model.py +0 -0
  25. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/flask_postgresql_audit/models.py +0 -0
  26. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/flask_postgresql_audit/templates/audit_trigger_delete.sql +0 -0
  27. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/flask_postgresql_audit/templates/audit_trigger_insert.sql +0 -0
  28. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/flask_postgresql_audit/templates/audit_trigger_update.sql +0 -0
  29. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/flask_postgresql_audit/templates/create_activity.sql +0 -0
  30. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/flask_postgresql_audit/templates/get_pk_values.sql +0 -0
  31. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/flask_postgresql_audit/templates/get_setting.sql +0 -0
  32. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/flask_postgresql_audit/templates/get_table_relid.sql +0 -0
  33. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/flask_postgresql_audit/templates/jsonb_subtract.sql +0 -0
  34. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/flask_postgresql_audit/typing.py +0 -0
  35. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/flask_postgresql_audit/utils.py +0 -0
  36. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/pyproject.toml +0 -0
  37. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/tests/__init__.py +0 -0
  38. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/tests/conftest.py +0 -0
  39. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/tests/migrations/README +0 -0
  40. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/tests/migrations/alembic.ini +0 -0
  41. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/tests/migrations/env.py +0 -0
  42. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/tests/migrations/script.py.mako +0 -0
  43. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/tests/test_defaults.py +0 -0
  44. {flask_postgresql_audit-1.1.1 → flask_postgresql_audit-1.1.2}/tests/utils.py +0 -0
@@ -67,3 +67,4 @@ uv.lock
67
67
 
68
68
  # Agents
69
69
  .agents
70
+ AGENTS.md
@@ -3,6 +3,12 @@
3
3
  Here you can see the full list of changes between each Flask-PostgreSQL-Audit
4
4
  release.
5
5
 
6
+ ## 1.1.2 (2026-08-21)
7
+
8
+ - Add dedicated `jsonb_array_subtract()` function to support set difference operations on JSONB arrays.
9
+ - Add unit tests for `DocumentStaging` and `PGAggregate` extensions.
10
+ - Add `reorder_migration_ops` troubleshooting guide to `README.md`.
11
+
6
12
  ## 1.1.1 (2026-08-08)
7
13
 
8
14
  - Fix test teardown downgrade error in migration listener.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: flask-postgresql-audit
3
- Version: 1.1.1
3
+ Version: 1.1.2
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>
@@ -245,6 +245,29 @@ The `fetch_activity()` method works with:
245
245
  - A single instance: `audit.fetch_activity(my_instance)`
246
246
  - Multiple instances: `audit.fetch_activity([instance1, instance2])`
247
247
 
248
+ ### Migration dependency or trigger ordering issues?
249
+
250
+ If Alembic migration generation creates triggers before tables or schema dependencies:
251
+
252
+ Use `reorder_migration_ops` and `chain_revision_directives` in `alembic/env.py`:
253
+
254
+ ```python
255
+ from flask_postgresql_audit.alembic import (
256
+ chain_revision_directives,
257
+ reorder_migration_ops,
258
+ )
259
+
260
+ context.configure(
261
+ connection=connection,
262
+ target_metadata=target_metadata,
263
+ include_schemas=True,
264
+ process_revision_directives=chain_revision_directives(
265
+ reorder_migration_ops,
266
+ process_revision_directives,
267
+ ),
268
+ )
269
+ ```
270
+
248
271
  ## Extensions
249
272
 
250
273
  ### Document Staging
@@ -218,6 +218,29 @@ The `fetch_activity()` method works with:
218
218
  - A single instance: `audit.fetch_activity(my_instance)`
219
219
  - Multiple instances: `audit.fetch_activity([instance1, instance2])`
220
220
 
221
+ ### Migration dependency or trigger ordering issues?
222
+
223
+ If Alembic migration generation creates triggers before tables or schema dependencies:
224
+
225
+ Use `reorder_migration_ops` and `chain_revision_directives` in `alembic/env.py`:
226
+
227
+ ```python
228
+ from flask_postgresql_audit.alembic import (
229
+ chain_revision_directives,
230
+ reorder_migration_ops,
231
+ )
232
+
233
+ context.configure(
234
+ connection=connection,
235
+ target_metadata=target_metadata,
236
+ include_schemas=True,
237
+ process_revision_directives=chain_revision_directives(
238
+ reorder_migration_ops,
239
+ process_revision_directives,
240
+ ),
241
+ )
242
+ ```
243
+
221
244
  ## Extensions
222
245
 
223
246
  ### Document Staging
@@ -1,4 +1,4 @@
1
1
  # ruff: noqa: F401
2
2
  from .core import Audit, PostgreSQLAudit
3
3
 
4
- __version__ = "1.1.1"
4
+ __version__ = "1.1.2"
@@ -81,3 +81,12 @@ def jsonb_subtract_factory(**context):
81
81
  signature="jsonb_subtract(arg1 jsonb, arg2 jsonb)",
82
82
  definition=sql["definition"],
83
83
  )
84
+
85
+
86
+ def jsonb_array_subtract_factory(**context):
87
+ sql = parse_template("jsonb_array_subtract.sql", **context)
88
+ return PGFunction(
89
+ schema=context["schema_name"],
90
+ signature="jsonb_array_subtract(arg1 jsonb, arg2 jsonb)",
91
+ definition=sql["definition"],
92
+ )
@@ -128,6 +128,7 @@ def register_core_entities(audit: "PostgreSQLAudit"):
128
128
  audit.pg_audit_entities.add(entities.get_table_relid(**audit.context))
129
129
  audit.pg_audit_entities.add(entities.get_setting_factory(**audit.context))
130
130
  audit.pg_audit_entities.add(entities.jsonb_subtract_factory(**audit.context))
131
+ audit.pg_audit_entities.add(entities.jsonb_array_subtract_factory(**audit.context))
131
132
  audit.pg_audit_entities.add(entities.create_activity_factory(**audit.context))
132
133
 
133
134
 
@@ -0,0 +1,29 @@
1
+ CREATE FUNCTION ${schema_prefix}jsonb_array_subtract(arg1 jsonb, arg2 jsonb) RETURNS jsonb AS $$
2
+ SELECT
3
+ CASE
4
+ WHEN jsonb_typeof(arg1) = 'array' THEN
5
+ COALESCE(
6
+ (
7
+ SELECT jsonb_agg(elem ORDER BY ord)
8
+ FROM jsonb_array_elements(arg1) WITH ORDINALITY AS t(elem, ord)
9
+ WHERE NOT (
10
+ CASE
11
+ WHEN arg2 IS NULL THEN FALSE
12
+ WHEN jsonb_typeof(arg2) = 'array' THEN
13
+ EXISTS (
14
+ SELECT 1
15
+ FROM jsonb_array_elements(arg2) AS old_elem
16
+ WHERE old_elem = elem
17
+ )
18
+ ELSE elem = arg2
19
+ END
20
+ )
21
+ ),
22
+ '[]'::jsonb
23
+ )
24
+ WHEN arg1 IS DISTINCT FROM arg2 THEN
25
+ arg1
26
+ ELSE
27
+ NULL
28
+ END
29
+ $$ LANGUAGE SQL;
@@ -7,6 +7,12 @@ from sqlalchemy import Table
7
7
  from sqlalchemy.orm import Mapped, mapped_column
8
8
 
9
9
  from flask_postgresql_audit import Audit, PostgreSQLAudit
10
+ from flask_postgresql_audit.extensions.document_staging import (
11
+ DocumentStaging,
12
+ attach_listener,
13
+ )
14
+
15
+ attach_listener(actor_id_getter=lambda: "test_actor")
10
16
 
11
17
 
12
18
  class DefaultConfig:
@@ -52,6 +58,12 @@ class Article(BaseModel, Audit):
52
58
  name: Mapped[str]
53
59
 
54
60
 
61
+ class Document(BaseModel, DocumentStaging, Audit):
62
+ __tablename__ = "document"
63
+ id: Mapped[int] = mapped_column(primary_key=True)
64
+ title: Mapped[str]
65
+
66
+
55
67
  app = Flask(__name__)
56
68
 
57
69
  app.config.from_object(DefaultConfig)
@@ -0,0 +1,106 @@
1
+ import pytest
2
+
3
+ from flask_postgresql_audit.extensions.document_staging import Docstatus
4
+
5
+ from .app import Document, db
6
+
7
+
8
+ @pytest.mark.usefixtures("test_client")
9
+ class TestDocumentStaging:
10
+ def test_document_creation(self):
11
+ doc = Document(title="Doc 1")
12
+ db.session.add(doc)
13
+ db.session.commit()
14
+
15
+ assert doc.docstatus == Docstatus.DRAFT
16
+ assert doc.created_by == "test_actor"
17
+ assert doc.created_on is not None
18
+
19
+ def test_document_bump_lifecycle(self):
20
+ doc = Document(title="Doc 2")
21
+ db.session.add(doc)
22
+ db.session.commit()
23
+
24
+ # DRAFT -> SUBMITTED
25
+ doc.bump()
26
+ db.session.commit()
27
+ assert doc.docstatus == Docstatus.SUBMITTED
28
+ assert doc.submitted_by == "test_actor"
29
+ assert doc.submitted_on is not None
30
+
31
+ # SUBMITTED -> CANCELLED
32
+ doc.bump()
33
+ db.session.commit()
34
+ assert doc.docstatus == Docstatus.CANCELLED
35
+ assert doc.cancelled_by == "test_actor"
36
+ assert doc.cancelled_on is not None
37
+
38
+ # Bumping CANCELLED raises ValueError
39
+ with pytest.raises(ValueError, match="cannot be bumped"):
40
+ doc.bump()
41
+
42
+ def test_document_revise(self):
43
+ doc1 = Document(title="Original")
44
+ db.session.add(doc1)
45
+ db.session.commit()
46
+
47
+ # Cannot revise DRAFT
48
+ with pytest.raises(ValueError, match="cannot be revised"):
49
+ doc1.revise(Document(title="V2"))
50
+
51
+ doc1.bump() # SUBMITTED
52
+ doc1.bump() # CANCELLED
53
+ db.session.commit()
54
+
55
+ doc2 = Document(title="V2")
56
+ revised_doc = doc1.revise(doc2)
57
+ db.session.add(revised_doc)
58
+ db.session.commit()
59
+
60
+ assert doc1.docstatus == Docstatus.REVISED
61
+ assert doc1.revision == doc2
62
+ assert doc2.revision_of == doc1
63
+ assert doc2.docstatus == Docstatus.DRAFT
64
+
65
+ def test_document_delete(self):
66
+ # Delete DRAFT
67
+ doc = Document(title="Draft to delete")
68
+ db.session.add(doc)
69
+ db.session.commit()
70
+ doc_id = doc.id
71
+
72
+ doc.delete(db.session)
73
+ db.session.commit()
74
+
75
+ assert db.session.get(Document, doc_id) is None
76
+
77
+ # Delete non-DRAFT raises ValueError
78
+ doc_submitted = Document(title="Submitted")
79
+ db.session.add(doc_submitted)
80
+ db.session.commit()
81
+ doc_submitted.bump()
82
+ db.session.commit()
83
+
84
+ with pytest.raises(ValueError, match="cannot be deleted"):
85
+ doc_submitted.delete(db.session)
86
+
87
+ def test_delete_revision_reverts_parent_status(self):
88
+ doc1 = Document(title="Original")
89
+ db.session.add(doc1)
90
+ db.session.commit()
91
+ doc1.bump()
92
+ doc1.bump() # CANCELLED
93
+ db.session.commit()
94
+
95
+ doc2 = Document(title="V2 Revision")
96
+ doc1.revise(doc2)
97
+ db.session.add(doc2)
98
+ db.session.commit()
99
+
100
+ assert doc1.docstatus == Docstatus.REVISED
101
+
102
+ # Delete revision doc2
103
+ doc2.delete(db.session)
104
+ db.session.commit()
105
+
106
+ assert doc1.docstatus == Docstatus.CANCELLED
@@ -31,3 +31,35 @@ def test_jsonb_subtract(test_client, old, new, result):
31
31
  )
32
32
  == result
33
33
  )
34
+
35
+
36
+ JSONB_ARRAY_SUBTRACT = (
37
+ "SELECT {schema}jsonb_array_subtract((:arg1)::jsonb, (:arg2)::jsonb)"
38
+ )
39
+
40
+
41
+ @pytest.mark.parametrize(
42
+ ("arg1", "arg2", "result"),
43
+ (
44
+ ([1, 2, 3], [2], [1, 3]),
45
+ ([1, 2, 3], [1, 2, 3], []),
46
+ ([1, 2, 3], [4, 5], [1, 2, 3]),
47
+ (["a", "b", "c"], ["b"], ["a", "c"]),
48
+ ([], [1, 2], []),
49
+ ([1, 2], [], [1, 2]),
50
+ ([1, 2, 2, 3], [2], [1, 3]),
51
+ ([{"a": 1}, {"b": 2}], [{"a": 1}], [{"b": 2}]),
52
+ ([1, 2, 3], 2, [1, 3]),
53
+ ([1, 2, 3], None, [1, 2, 3]),
54
+ ([1, 2], {"a": 1}, [1, 2]),
55
+ ({"a": 1}, [1, 2], {"a": 1}),
56
+ ),
57
+ )
58
+ def test_jsonb_array_subtract(test_client, arg1, arg2, result):
59
+ assert (
60
+ db.session.scalar(
61
+ text(JSONB_ARRAY_SUBTRACT.format(schema=audit.context["schema_prefix"])),
62
+ {"arg1": json.dumps(arg1), "arg2": json.dumps(arg2)},
63
+ )
64
+ == result
65
+ )
@@ -0,0 +1,96 @@
1
+ import pytest
2
+ from alembic_utils.exceptions import SQLParseFailure
3
+ from sqlalchemy import text
4
+
5
+ from flask_postgresql_audit.extensions.alembic_utils import PGAggregate
6
+ from tests.app import audit, db
7
+
8
+
9
+ @pytest.mark.usefixtures("test_client")
10
+ class TestPGAggregate:
11
+ def test_properties_and_signatures(self):
12
+ agg = PGAggregate(
13
+ schema="public",
14
+ signature="custom_sum(numeric)",
15
+ definition="(SFUNC = numeric_add, STYPE = numeric, INITCOND = '0')",
16
+ )
17
+ assert agg.type_ == "aggregate"
18
+ assert agg.literal_signature == '"custom_sum"(numeric)'
19
+
20
+ def test_from_sql_parsing(self):
21
+ sql = "CREATE AGGREGATE public.custom_sum(numeric) (SFUNC = numeric_add, STYPE = numeric, INITCOND = '0')"
22
+ agg = PGAggregate.from_sql(sql)
23
+
24
+ assert agg.schema == "public"
25
+ assert agg.signature == "custom_sum(numeric)"
26
+ assert (
27
+ agg.definition == "(SFUNC = numeric_add, STYPE = numeric, INITCOND = '0')"
28
+ )
29
+
30
+ # Quoted signature in SQL
31
+ sql_quoted = 'CREATE AGGREGATE public."custom_sum"(numeric) (SFUNC = numeric_add, STYPE = numeric)'
32
+ agg_quoted = PGAggregate.from_sql(sql_quoted)
33
+ assert agg_quoted.signature == "custom_sum(numeric)"
34
+
35
+ # Invalid SQL raises SQLParseFailure
36
+ with pytest.raises(SQLParseFailure):
37
+ PGAggregate.from_sql("INVALID SQL STATEMENT")
38
+
39
+ def test_sql_statement_generation(self):
40
+ agg = PGAggregate(
41
+ schema="public",
42
+ signature="custom_sum(numeric)",
43
+ definition="(SFUNC = numeric_add, STYPE = numeric)",
44
+ )
45
+
46
+ create_sql = str(agg.to_sql_statement_create())
47
+ assert (
48
+ create_sql
49
+ == 'CREATE AGGREGATE "public"."custom_sum"(numeric) (SFUNC = numeric_add, STYPE = numeric)'
50
+ )
51
+
52
+ create_or_replace = list(agg.to_sql_statement_create_or_replace())
53
+ assert len(create_or_replace) == 1
54
+ assert (
55
+ str(create_or_replace[0])
56
+ == 'CREATE OR REPLACE AGGREGATE "public"."custom_sum"(numeric) (SFUNC = numeric_add, STYPE = numeric)'
57
+ )
58
+
59
+ drop_sql = str(agg.to_sql_statement_drop())
60
+ assert drop_sql == 'DROP AGGREGATE "public"."custom_sum"(numeric)'
61
+
62
+ drop_cascade_sql = str(agg.to_sql_statement_drop(cascade=True))
63
+ assert (
64
+ drop_cascade_sql == 'DROP AGGREGATE "public"."custom_sum"(numeric) CASCADE'
65
+ )
66
+
67
+ def test_from_database(self):
68
+ schema = audit.context["schema_name"]
69
+ prefix = audit.context["schema_prefix"]
70
+
71
+ # Create temporary custom aggregate in DB
72
+ db.session.execute(
73
+ text(
74
+ f"""
75
+ CREATE OR REPLACE AGGREGATE {prefix}test_sum(numeric) (
76
+ SFUNC = numeric_add,
77
+ STYPE = numeric,
78
+ INITCOND = '0'
79
+ );
80
+ """
81
+ )
82
+ )
83
+ db.session.commit()
84
+
85
+ try:
86
+ aggregates = PGAggregate.from_database(db.session, schema=schema) # type: ignore
87
+ matching = [
88
+ a for a in aggregates if a.identity.endswith("test_sum(numeric)")
89
+ ]
90
+ assert len(matching) == 1
91
+ assert matching[0].schema == schema
92
+ finally:
93
+ db.session.execute(
94
+ text(f"DROP AGGREGATE IF EXISTS {prefix}test_sum(numeric);")
95
+ )
96
+ db.session.commit()