Skip to content

Commit 7476a59

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

File tree

10 files changed

+295
-30
lines changed

10 files changed

+295
-30
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: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,7 @@ def get_create_trigger_ddl(
2424
session_settings = session_settings or []
2525

2626
deletion_elements = ["'D'", "now()", "current_user"]
27-
2827
updation_elements = ["'U'", "now()", "current_user"]
29-
3028
insertion_elements = ["'I'", "now()", "current_user"]
3129

3230
setting_map = {
@@ -43,18 +41,24 @@ def get_create_trigger_ddl(
4341
else col.name
4442
)
4543

46-
# We need to make sure to explicitly reference all elements in the procedure
47-
column_elements.append(column_name)
48-
4944
# If this value is coming out of the target, then we want to explicitly reference the value
50-
if col.name in target_columns:
45+
if col.name == "audit_pk":
46+
continue
47+
48+
elif col.name in target_columns:
5149
deletion_elements.append("OLD.{}".format(column_name))
5250
updation_elements.append("NEW.{}".format(column_name))
5351
insertion_elements.append("NEW.{}".format(column_name))
5452

53+
# We need to make sure to explicitly reference all elements in the procedure
54+
column_elements.append(column_name)
55+
5556
# If it is not, it is either a default "audit_*" column
5657
# or it is one of our session settings values
5758
else:
59+
# We need to make sure to explicitly reference all elements in the procedure
60+
column_elements.append(column_name)
61+
5862
if col.name in (
5963
"audit_operation",
6064
"audit_operation_timestamp",
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
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 (
11+
create_audit_table as create_raw_audit_table,
12+
)
13+
14+
15+
default_primary_key = Column(
16+
"audit_pk",
17+
UUID(as_uuid=True),
18+
primary_key=True,
19+
default=uuid.uuid4,
20+
server_default=text("uuid_generate_v4()"),
21+
)
22+
23+
24+
def audit_model(_func=None, *, enabled=True, primary_key=default_primary_key, **spec):
25+
"""Decorate a model to automatically enable audit modeling.
26+
27+
Arguments:
28+
enabled: Defaults to true, enables auditing.
29+
primary_key: Default to a uuid primary key. Can be disabled by using `None`.
30+
31+
By default, automatically enables the auditing in addition to hooking
32+
up the actual audit machinery.
33+
34+
Additionally, leaves a reference to the audit model's own sqlachemy model
35+
on the ``__audit_cls__`` attribute of the decorated class.
36+
37+
Examples:
38+
>>> from sqlalchemy import Column, types
39+
>>> from sqlalchemy.ext.declarative import declarative_base
40+
>>> from sqlalchemy_postgresql_audit import audit_model
41+
42+
>>> Base = declarative_base()
43+
44+
>>> @audit_model
45+
... class Foo(Base):
46+
... __tablename__ = 'foo'
47+
... id = Column(types.Integer(), primary_key=True)
48+
49+
>>> Foo.__audit_cls__
50+
<class '...FooAudit'>
51+
52+
>>> @audit_model(enabled=False)
53+
... class Bar(Base):
54+
... __tablename__ = 'bar'
55+
... id = Column(types.Integer(), primary_key=True)
56+
"""
57+
58+
def decorated(model_cls):
59+
model = create_audit_model(
60+
model_cls, enabled=enabled, primary_key=primary_key, **spec
61+
)
62+
if model:
63+
model_cls.__audit_cls__ = model
64+
65+
return model_cls
66+
67+
if _func is None:
68+
return decorated
69+
return decorated(_func)
70+
71+
72+
def create_audit_model(
73+
model_cls, *, enabled=True, primary_key=default_primary_key, **spec
74+
):
75+
"""Create an SQLAlchemy declarative Model class for the given `model_cls`.
76+
77+
Arguments:
78+
model_cls: The SQLAlchemy model being audited
79+
enabled: Defaults to true, enables auditing.
80+
primary_key: Default to a uuid primary key. Can be disabled by using `None`.
81+
82+
Examples:
83+
>>> from sqlalchemy import Column, types
84+
>>> from sqlalchemy.ext.declarative import declarative_base
85+
>>> from sqlalchemy_postgresql_audit import create_audit_model
86+
87+
>>> Base = declarative_base()
88+
89+
>>> class Foo(Base):
90+
... __tablename__ = 'foo'
91+
... id = Column(types.Integer(), primary_key=True)
92+
93+
>>> class Bar(Base):
94+
... __tablename__ = 'bar'
95+
... id = Column(types.Integer(), primary_key=True)
96+
97+
>>> class Baz(Base):
98+
... __tablename__ = 'baz'
99+
... id = Column(types.Integer(), primary_key=True)
100+
101+
>>> AuditModel = create_audit_model(Foo)
102+
>>> AuditModel3 = create_audit_model(Baz, primary_key=default_primary_key)
103+
>>> create_audit_model(Bar, enabled=False)
104+
"""
105+
base_table = model_cls.__table__
106+
metadata = model_cls.metadata
107+
108+
table = create_audit_table(
109+
base_table, metadata, enabled=enabled, primary_key=primary_key, **spec
110+
)
111+
if table is None:
112+
return
113+
114+
model_base = _find_model_base(model_cls)
115+
116+
cls = type(
117+
"{model_cls}Audit".format(model_cls=model_cls.__name__),
118+
(model_base,),
119+
{"__table__": table},
120+
)
121+
122+
return cls
123+
124+
125+
def create_audit_table(
126+
table,
127+
metadata,
128+
*,
129+
enabled=True,
130+
primary_key=default_primary_key,
131+
ignore_columns=(),
132+
**spec
133+
):
134+
"""Create an audit SQLAlchemy ``Table`` for a given `Table` instance.
135+
136+
Arguments:
137+
table: The SQLAlchemy `Table` to audit.
138+
metadata: The `SQLAlchemy` metadata on which to attach the table.
139+
enabled: Defaults to true, enables auditing.
140+
primary_key: Default to a uuid primary key. Can be disabled by using `None`.
141+
spec: Optional auditing spec options.
142+
143+
Examples:
144+
>>> from sqlalchemy import MetaData, Table
145+
>>> from sqlalchemy_postgresql_audit import create_audit_table
146+
147+
>>> meta = MetaData()
148+
149+
>>> foo_table = Table('foo', meta)
150+
>>> audit_table1 = create_audit_table(foo_table, meta)
151+
152+
>>> baz_table = Table('baz', meta)
153+
>>> audit_table3 = create_audit_table(baz_table, meta, primary_key=None)
154+
155+
>>> bar_table = Table('bar', meta)
156+
>>> create_audit_table(bar_table, meta, enabled=False)
157+
"""
158+
existing_info = table.info
159+
existing_info["audit.options"] = {"enabled": enabled, **spec}
160+
161+
return create_raw_audit_table(
162+
table,
163+
metadata,
164+
primary_key=primary_key,
165+
ignore_columns=ignore_columns,
166+
)
167+
168+
169+
def _find_model_base(model_cls):
170+
for cls in model_cls.__mro__:
171+
if isinstance(cls, DeclarativeMeta) and not hasattr(cls, "__mapper__"):
172+
return cls
173+
174+
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: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@
1818
)
1919

2020

21-
def create_audit_table(target, parent):
21+
def create_audit_table(
22+
target,
23+
parent,
24+
primary_key=None,
25+
ignore_columns=(),
26+
):
2227
"""Create an audit table and generate procedure/trigger DDL.
2328
2429
Naming conventions can be defined for a few of the named elements:
@@ -83,23 +88,32 @@ def create_audit_table(target, parent):
8388
"schema": audit_spec["schema"] or "public",
8489
}
8590

86-
columns = [
87-
Column(col.name, col.type, nullable=True) for col in target.columns.values()
88-
]
91+
column_elements = []
92+
if primary_key is not None:
93+
column_elements.append(primary_key)
94+
95+
column_elements.extend(
96+
[
97+
Column("audit_operation", String(1), nullable=False),
98+
Column("audit_operation_timestamp", DateTime, nullable=False),
99+
Column("audit_current_user", String(64), nullable=False),
100+
]
101+
)
102+
89103
session_setting_columns = [col.copy() for col in audit_spec["session_settings"]]
90104
for col in session_setting_columns:
91105
col.name = "audit_{}".format(col.name)
106+
column_elements.extend(session_setting_columns)
92107

93-
column_elements = session_setting_columns + columns
108+
table_columns = [
109+
Column(col.name, col.type, nullable=True)
110+
for col in target.columns.values()
111+
if col.name not in ignore_columns
112+
]
113+
column_elements.extend(table_columns)
94114

95115
audit_table = Table(
96-
audit_table_name,
97-
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),
101-
*column_elements,
102-
schema=audit_spec["schema"]
116+
audit_table_name, target.metadata, *column_elements, schema=audit_spec["schema"]
103117
)
104118

105119
target.info["audit.create_ddl"] = get_create_trigger_ddl(
@@ -119,3 +133,4 @@ def create_audit_table(target, parent):
119133

120134
audit_table.info["audit.is_audit_table"] = True
121135
target.info["audit.is_audited"] = True
136+
return audit_table

0 commit comments

Comments
 (0)