Skip to content

Commit ef23799

Browse files
committed
feat: Add a more ergonomic API for enabling auditing on models.
1 parent e4f9d94 commit ef23799

File tree

10 files changed

+257
-20
lines changed

10 files changed

+257
-20
lines changed

docs/Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
# You can set these variables from the command line, and also
55
# from the environment for the first two.
6-
SPHINXOPTS ?=-W -a -E
6+
SPHINXOPTS ?= -a -E
77
SPHINXBUILD ?= sphinx-build
88
SOURCEDIR = source
99
BUILDDIR = build

docs/source/api.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,17 @@ API
1212

1313
.. autofunction:: sqlalchemy_postgresql_audit.uninstall_audit_triggers
1414
:noindex:
15+
16+
Declarative API
17+
===============
18+
An alternative API for the use and enablement of auditing functionality
19+
can be used more directly on existing models/tables.
20+
21+
.. autofunction:: sqlalchemy_postgresql_audit.audit_model
22+
:noindex:
23+
24+
.. autofunction:: sqlalchemy_postgresql_audit.create_audit_model
25+
:noindex:
26+
27+
.. autofunction:: sqlalchemy_postgresql_audit.create_audit_table
28+
:noindex:

setup.cfg

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,15 @@ max-line-length = 100
77

88
[black]
99
line-length = 100
10+
11+
[isort]
12+
profile = black
13+
known_first_party = sqlalchemy_postgresql_audit,tests
14+
line_length = 100
15+
float_to_top=true
16+
order_by_type = false
17+
use_parentheses = true
18+
19+
[tool:pytest]
20+
doctest_optionflags = NORMALIZE_WHITESPACE IGNORE_EXCEPTION_DETAIL ELLIPSIS
21+
addopts = --doctest-modules --ff --strict-markers

src/sqlalchemy_postgresql_audit/__init__.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@
55
"enable",
66
"install_audit_triggers",
77
"uninstall_audit_triggers",
8+
"audit_model",
9+
"create_audit_model",
10+
"create_audit_table",
811
]
912

10-
from .session import set_session_vars
11-
from .plugin import enable
13+
from .declarative import audit_model, create_audit_model, create_audit_table
1214
from .ddl import install_audit_triggers, uninstall_audit_triggers
15+
from .plugin import enable
16+
from .session import set_session_vars

src/sqlalchemy_postgresql_audit/ddl.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ def get_create_trigger_ddl(
5656
# or it is one of our session settings values
5757
else:
5858
if col.name in (
59+
"audit_uuid",
5960
"audit_operation",
6061
"audit_operation_timestamp",
6162
"audit_current_user",
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import uuid
2+
from sqlalchemy.dialects.postgresql import UUID
3+
from sqlalchemy import Column, text
4+
5+
try:
6+
from sqlalchemy.orm.decl_api import DeclarativeMeta
7+
except ImportError:
8+
from sqlalchemy.ext.declarative.api import DeclarativeMeta
9+
10+
from sqlalchemy_postgresql_audit.event_listeners.sqlalchemy import create_audit_table as create_raw_audit_table
11+
12+
13+
def default_primary_key():
14+
return Column(
15+
'audit_uuid',
16+
UUID(as_uuid=True),
17+
primary_key=True,
18+
default=uuid.uuid4,
19+
server_default=text("uuid_generate_v4()"),
20+
)
21+
22+
23+
def audit_model(_func=None, *, enabled=True, primary_key=default_primary_key, **spec):
24+
"""Decorate a model to automatically enable audit modeling.
25+
26+
Arguments:
27+
enabled: Defaults to true, enables auditing.
28+
primary_key: Default to a uuid primary key. Can be disabled by using `None`.
29+
30+
By default, automatically enables the auditing in addition to hooking
31+
up the actual audit machinery.
32+
33+
Additionally, leaves a reference to the audit model's own sqlachemy model
34+
on the ``__audit_cls__`` attribute of the decorated class.
35+
36+
Examples:
37+
>>> from sqlalchemy import Column, types
38+
>>> from sqlalchemy.ext.declarative import declarative_base
39+
>>> from sqlalchemy_postgresql_audit import audit_model
40+
41+
>>> Base = declarative_base()
42+
43+
>>> @audit_model
44+
... class Foo(Base):
45+
... __tablename__ = 'foo'
46+
... id = Column(types.Integer(), primary_key=True)
47+
48+
>>> Foo.__audit_cls__
49+
<class '...FooAudit'>
50+
51+
>>> @audit_model(enabled=False)
52+
... class Bar(Base):
53+
... __tablename__ = 'bar'
54+
... id = Column(types.Integer(), primary_key=True)
55+
"""
56+
def decorated(model_cls):
57+
model = create_audit_model(model_cls, enabled=enabled, **spec)
58+
if model:
59+
model_cls.__audit_cls__ = model
60+
61+
return model_cls
62+
63+
if _func is None:
64+
return decorated
65+
return decorated(_func)
66+
67+
68+
def create_audit_model(model_cls, *, enabled=True, primary_key=default_primary_key, **spec):
69+
"""Create an SQLAlchemy declarative Model class for the given `model_cls`.
70+
71+
Arguments:
72+
model_cls: The SQLAlchemy model being audited
73+
enabled: Defaults to true, enables auditing.
74+
primary_key: Default to a uuid primary key. Can be disabled by using `None`.
75+
76+
Examples:
77+
>>> from sqlalchemy import Column, types
78+
>>> from sqlalchemy.ext.declarative import declarative_base
79+
>>> from sqlalchemy_postgresql_audit import create_audit_model
80+
81+
>>> Base = declarative_base()
82+
83+
>>> class Foo(Base):
84+
... __tablename__ = 'foo'
85+
... id = Column(types.Integer(), primary_key=True)
86+
87+
>>> class Bar(Base):
88+
... __tablename__ = 'bar'
89+
... id = Column(types.Integer(), primary_key=True)
90+
91+
>>> class Baz(Base):
92+
... __tablename__ = 'baz'
93+
... id = Column(types.Integer(), primary_key=True)
94+
95+
>>> AuditModel = create_audit_model(Foo)
96+
>>> AuditModel3 = create_audit_model(Baz, primary_key=default_primary_key)
97+
>>> create_audit_model(Bar, enabled=False)
98+
"""
99+
base_table = model_cls.__table__
100+
metadata = model_cls.metadata
101+
102+
table = create_audit_table(base_table, metadata, enabled=enabled, primary_key=primary_key, **spec)
103+
if table is None:
104+
return
105+
106+
model_base = _find_model_base(model_cls)
107+
108+
cls = type(
109+
'{model_cls}Audit'.format(model_cls=model_cls.__name__),
110+
(model_base,),
111+
{'__table__': table},
112+
)
113+
114+
return cls
115+
116+
117+
def create_audit_table(table, metadata, *, enabled=True, primary_key=default_primary_key, **spec):
118+
"""Create an audit SQLAlchemy ``Table`` for a given `Table` instance.
119+
120+
Arguments:
121+
table: The SQLAlchemy `Table` to audit.
122+
metadata: The `SQLAlchemy` metadata on which to attach the table.
123+
enabled: Defaults to true, enables auditing.
124+
primary_key: Default to a uuid primary key. Can be disabled by using `None`.
125+
spec: Optional auditing spec options.
126+
127+
Examples:
128+
>>> from sqlalchemy import MetaData, Table
129+
>>> from sqlalchemy_postgresql_audit import create_audit_table
130+
131+
>>> meta = MetaData()
132+
133+
>>> foo_table = Table('foo', meta)
134+
>>> audit_table1 = create_audit_table(foo_table, meta)
135+
136+
>>> baz_table = Table('baz', meta)
137+
>>> audit_table3 = create_audit_table(baz_table, meta, primary_key=None)
138+
139+
>>> bar_table = Table('bar', meta)
140+
>>> create_audit_table(bar_table, meta, enabled=False)
141+
"""
142+
existing_info = table.info
143+
existing_info['audit.options'] = {'enabled': enabled, **spec}
144+
145+
primary_key_column = primary_key() if primary_key else None
146+
147+
return create_raw_audit_table(table, metadata, primary_key=primary_key_column)
148+
149+
150+
def _find_model_base(model_cls):
151+
for cls in model_cls.__mro__:
152+
if isinstance(cls, DeclarativeMeta) and not hasattr(cls, '__mapper__'):
153+
return cls
154+
155+
raise ValueError("Invalid model, does not subclass a `DeclarativeMeta`.")
Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import threading
22

3-
from sqlalchemy import Table
4-
from sqlalchemy.events import event
3+
from sqlalchemy import Table, event
54

65
_event_listeners_enabled = False
76

@@ -17,20 +16,19 @@ def enable_event_listeners():
1716

1817

1918
def _enable_sqlalchemy_event_listeners():
20-
from sqlalchemy_postgresql_audit.event_listeners.sqlalchemy import (
21-
create_audit_table,
22-
)
19+
from sqlalchemy_postgresql_audit.event_listeners.sqlalchemy import \
20+
create_audit_table
2321

2422
event.listens_for(Table, "after_parent_attach")(create_audit_table)
2523

2624

2725
def _enable_alembic_event_listeners():
2826
try:
29-
from sqlalchemy_postgresql_audit.event_listeners.alembic import (
30-
compare_for_table,
31-
)
3227
from alembic.autogenerate.compare import comparators
3328

29+
from sqlalchemy_postgresql_audit.event_listeners.alembic import \
30+
compare_for_table
31+
3432
comparators.dispatch_for("table")(compare_for_table)
3533
except ImportError:
3634
pass

src/sqlalchemy_postgresql_audit/event_listeners/sqlalchemy.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
)
1919

2020

21-
def create_audit_table(target, parent):
21+
def create_audit_table(target, parent, primary_key=None):
2222
"""Create an audit table and generate procedure/trigger DDL.
2323
2424
Naming conventions can be defined for a few of the named elements:
@@ -83,21 +83,29 @@ def create_audit_table(target, parent):
8383
"schema": audit_spec["schema"] or "public",
8484
}
8585

86-
columns = [
87-
Column(col.name, col.type, nullable=True) for col in target.columns.values()
88-
]
86+
column_elements = []
87+
if primary_key is not None:
88+
column_elements.append(primary_key)
89+
90+
column_elements.extend([
91+
Column("audit_operation", String(1), nullable=False),
92+
Column("audit_operation_timestamp", DateTime, nullable=False),
93+
Column("audit_current_user", String(64), nullable=False),
94+
])
95+
8996
session_setting_columns = [col.copy() for col in audit_spec["session_settings"]]
9097
for col in session_setting_columns:
9198
col.name = "audit_{}".format(col.name)
99+
column_elements.extend(session_setting_columns)
92100

93-
column_elements = session_setting_columns + columns
101+
table_columns = [
102+
Column(col.name, col.type, nullable=True) for col in target.columns.values()
103+
]
104+
column_elements.extend(table_columns)
94105

95106
audit_table = Table(
96107
audit_table_name,
97108
target.metadata,
98-
Column("audit_operation", String(1), nullable=False),
99-
Column("audit_operation_timestamp", DateTime, nullable=False),
100-
Column("audit_current_user", String(64), nullable=False),
101109
*column_elements,
102110
schema=audit_spec["schema"]
103111
)
@@ -119,3 +127,4 @@ def create_audit_table(target, parent):
119127

120128
audit_table.info["audit.is_audit_table"] = True
121129
target.info["audit.is_audited"] = True
130+
return audit_table
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from sqlalchemy import Column, Integer, MetaData, Table
2+
from sqlalchemy.ext.declarative import declarative_base
3+
4+
import sqlalchemy_postgresql_audit
5+
6+
7+
def setup():
8+
sqlalchemy_postgresql_audit.enable()
9+
10+
11+
def test_vanilla_model():
12+
Base = declarative_base()
13+
metadata = Base.metadata
14+
15+
@sqlalchemy_postgresql_audit.audit_model
16+
class Model(Base):
17+
__tablename__ = 'foo'
18+
19+
id = Column("id", Integer, primary_key=True)
20+
21+
audit_table = metadata.tables["foo_audit"]
22+
23+
assert audit_table.info["audit.is_audit_table"]
24+
assert Model.__table__.info["audit.is_audited"]
25+
26+
27+
def test_model_with_info():
28+
Base = declarative_base()
29+
metadata = Base.metadata
30+
31+
@sqlalchemy_postgresql_audit.audit_model
32+
class Model(Base):
33+
__tablename__ = 'foo'
34+
__table_args__ = {
35+
'info': {'example': 4}
36+
}
37+
38+
id = Column("id", Integer, primary_key=True)
39+
40+
audit_table = metadata.tables["foo_audit"]
41+
42+
assert audit_table.info["audit.is_audit_table"]
43+
assert Model.__table__.info["audit.is_audited"]
44+
assert 'example' in Model.__table__.info

tox.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ basepython =
1313

1414
commands =
1515
pip install sqlalchemy-postgresql-audit[testing]
16-
pytest --cov sqlalchemy_postgresql_audit --cov-report= -v tests {posargs:}
16+
pytest --cov sqlalchemy_postgresql_audit --cov-report= -v src tests {posargs:}
1717

1818
setenv =
1919
COVERAGE_FILE=tmp/.coverage.{envname}

0 commit comments

Comments
 (0)