1
|
|
|
"""Versioned mixin class and other utilities.""" |
2
|
|
|
|
3
|
|
|
import datetime |
4
|
|
|
|
5
|
|
|
from sqlalchemy.ext.declarative import declared_attr |
6
|
|
|
from sqlalchemy.orm import mapper, attributes, object_mapper |
7
|
|
|
from sqlalchemy.orm.exc import UnmappedColumnError |
8
|
|
|
from sqlalchemy import Table, Column, ForeignKeyConstraint, Integer, ForeignKey |
9
|
|
|
from sqlalchemy import event |
10
|
|
|
from sqlalchemy.orm.properties import RelationshipProperty |
11
|
|
|
|
12
|
|
|
from sqlalchemy_utils import ArrowType |
13
|
|
|
|
14
|
|
|
def col_references_table(col, table): |
15
|
|
|
for fk in col.foreign_keys: |
16
|
|
|
if fk.references(table): |
17
|
|
|
return True |
18
|
|
|
return False |
19
|
|
|
|
20
|
|
|
def _is_versioning_col(col): |
21
|
|
|
return "version_meta" in col.info |
22
|
|
|
|
23
|
|
|
def _history_mapper(local_mapper): |
24
|
|
|
cls = local_mapper.class_ |
25
|
|
|
|
26
|
|
|
# set the "active_history" flag |
27
|
|
|
# on on column-mapped attributes so that the old version |
28
|
|
|
# of the info is always loaded (currently sets it on all attributes) |
29
|
|
|
for prop in local_mapper.iterate_properties: |
30
|
|
|
getattr(local_mapper.class_, prop.key).impl.active_history = True |
31
|
|
|
|
32
|
|
|
super_mapper = local_mapper.inherits |
33
|
|
|
super_history_mapper = getattr(cls, '__history_mapper__', None) |
34
|
|
|
|
35
|
|
|
polymorphic_on = None |
36
|
|
|
super_fks = [] |
37
|
|
|
|
38
|
|
|
def _col_copy(col): |
39
|
|
|
col = col.copy() |
40
|
|
|
col.unique = False |
41
|
|
|
col.default = col.server_default = None |
42
|
|
|
return col |
43
|
|
|
|
44
|
|
|
if not super_mapper or local_mapper.local_table is not super_mapper.local_table: |
45
|
|
|
cols = [] |
46
|
|
|
for column in local_mapper.local_table.c: |
47
|
|
|
if _is_versioning_col(column): |
48
|
|
|
continue |
49
|
|
|
|
50
|
|
|
col = _col_copy(column) |
51
|
|
|
|
52
|
|
|
if super_mapper and col_references_table(column, super_mapper.local_table): |
53
|
|
|
super_fks.append((col.key, list(super_history_mapper.local_table.primary_key)[0])) |
54
|
|
|
|
55
|
|
|
cols.append(col) |
56
|
|
|
|
57
|
|
|
if column is local_mapper.polymorphic_on: |
58
|
|
|
polymorphic_on = col |
59
|
|
|
|
60
|
|
|
if super_mapper: |
61
|
|
|
super_fks.append(('version', super_history_mapper.local_table.c.version)) |
62
|
|
|
|
63
|
|
|
version_meta = {"version_meta": True} # add column.info to identify |
64
|
|
|
# columns specific to versioning |
65
|
|
|
|
66
|
|
|
# "version" stores the integer version id. This column is |
67
|
|
|
# required. |
68
|
|
|
cols.append(Column('version', Integer, primary_key=True, |
69
|
|
|
autoincrement=False, info=version_meta)) |
70
|
|
|
|
71
|
|
|
# "changed" column stores the UTC timestamp of when the |
72
|
|
|
# history row was created. |
73
|
|
|
# This column is optional and can be omitted. |
74
|
|
|
model_name = cls.__name__.lower() |
75
|
|
|
cols.append(Column('%s_changed_at' % model_name, ArrowType, |
76
|
|
|
default=datetime.datetime.utcnow, |
77
|
|
|
info=version_meta)) |
78
|
|
|
cols.append(Column('%s_changed_by' % model_name, Integer, ForeignKey("users.id"), |
79
|
|
|
info=version_meta)) |
80
|
|
|
|
81
|
|
|
if super_fks: |
82
|
|
|
cols.append(ForeignKeyConstraint(*zip(*super_fks))) |
83
|
|
|
|
84
|
|
|
table = Table(local_mapper.local_table.name + '_history', |
85
|
|
|
local_mapper.local_table.metadata, |
86
|
|
|
*cols, |
87
|
|
|
schema=local_mapper.local_table.schema |
88
|
|
|
) |
89
|
|
|
else: |
90
|
|
|
# single table inheritance. take any additional columns that may have |
91
|
|
|
# been added and add them to the history table. |
92
|
|
|
for column in local_mapper.local_table.c: |
93
|
|
|
if column.key not in super_history_mapper.local_table.c: |
94
|
|
|
col = _col_copy(column) |
95
|
|
|
super_history_mapper.local_table.append_column(col) |
96
|
|
|
table = None |
97
|
|
|
|
98
|
|
|
if super_history_mapper: |
99
|
|
|
bases = (super_history_mapper.class_,) |
100
|
|
|
else: |
101
|
|
|
bases = local_mapper.base_mapper.class_.__bases__ |
102
|
|
|
versioned_cls = type.__new__(type, "%sHistory" % cls.__name__, bases, {}) |
103
|
|
|
|
104
|
|
|
m = mapper( |
105
|
|
|
versioned_cls, |
106
|
|
|
table, |
107
|
|
|
inherits=super_history_mapper, |
108
|
|
|
polymorphic_on=polymorphic_on, |
109
|
|
|
polymorphic_identity=local_mapper.polymorphic_identity |
110
|
|
|
) |
111
|
|
|
cls.__history_mapper__ = m |
112
|
|
|
|
113
|
|
|
if not super_history_mapper: |
114
|
|
|
local_mapper.local_table.append_column( |
115
|
|
|
Column('version', Integer, default=1, nullable=False) |
116
|
|
|
) |
117
|
|
|
local_mapper.add_property("version", local_mapper.local_table.c.version) |
118
|
|
|
|
119
|
|
|
|
120
|
|
|
class Versioned(object): |
121
|
|
|
@declared_attr |
122
|
|
|
def __mapper_cls__(cls): |
123
|
|
|
def map(cls, *arg, **kw): |
124
|
|
|
mp = mapper(cls, *arg, **kw) |
125
|
|
|
_history_mapper(mp) |
126
|
|
|
return mp |
127
|
|
|
return map |
128
|
|
|
|
129
|
|
|
|
130
|
|
|
def versioned_objects(iter): |
131
|
|
|
for obj in iter: |
132
|
|
|
if hasattr(obj, '__history_mapper__'): |
133
|
|
|
yield obj |
134
|
|
|
|
135
|
|
|
def create_version(obj, session, deleted=False): |
136
|
|
|
obj_mapper = object_mapper(obj) |
137
|
|
|
history_mapper = obj.__history_mapper__ |
138
|
|
|
history_cls = history_mapper.class_ |
139
|
|
|
|
140
|
|
|
obj_state = attributes.instance_state(obj) |
141
|
|
|
|
142
|
|
|
attr = {} |
143
|
|
|
|
144
|
|
|
obj_changed = False |
145
|
|
|
|
146
|
|
|
for om, hm in zip(obj_mapper.iterate_to_root(), history_mapper.iterate_to_root()): |
147
|
|
|
if hm.single: |
148
|
|
|
continue |
149
|
|
|
|
150
|
|
|
for hist_col in hm.local_table.c: |
151
|
|
|
if _is_versioning_col(hist_col): |
152
|
|
|
continue |
153
|
|
|
|
154
|
|
|
obj_col = om.local_table.c[hist_col.key] |
155
|
|
|
|
156
|
|
|
# get the value of the |
157
|
|
|
# attribute based on the MapperProperty related to the |
158
|
|
|
# mapped column. this will allow usage of MapperProperties |
159
|
|
|
# that have a different keyname than that of the mapped column. |
160
|
|
|
try: |
161
|
|
|
prop = obj_mapper.get_property_by_column(obj_col) |
162
|
|
|
except UnmappedColumnError: |
163
|
|
|
# in the case of single table inheritance, there may be |
164
|
|
|
# columns on the mapped table intended for the subclass only. |
165
|
|
|
# the "unmapped" status of the subclass column on the |
166
|
|
|
# base class is a feature of the declarative module as of sqla 0.5.2. |
167
|
|
|
continue |
168
|
|
|
|
169
|
|
|
# expired object attributes and also deferred cols might not be in the |
170
|
|
|
# dict. force it to load no matter what by using getattr(). |
171
|
|
|
if prop.key not in obj_state.dict: |
172
|
|
|
getattr(obj, prop.key) |
173
|
|
|
|
174
|
|
|
a, u, d = attributes.get_history(obj, prop.key) |
175
|
|
|
|
176
|
|
|
if d: |
177
|
|
|
attr[hist_col.key] = d[0] |
178
|
|
|
obj_changed = True |
179
|
|
|
elif u: |
180
|
|
|
attr[hist_col.key] = u[0] |
181
|
|
|
else: |
182
|
|
|
# if the attribute had no value. |
183
|
|
|
attr[hist_col.key] = a[0] |
184
|
|
|
obj_changed = True |
185
|
|
|
|
186
|
|
|
if not obj_changed: |
187
|
|
|
# not changed, but we have relationships. OK |
188
|
|
|
# check those too |
189
|
|
|
for prop in obj_mapper.iterate_properties: |
190
|
|
|
if isinstance(prop, RelationshipProperty) and \ |
191
|
|
|
attributes.get_history(obj, prop.key, |
192
|
|
|
passive=attributes.PASSIVE_NO_INITIALIZE).has_changes(): |
193
|
|
|
for p in prop.local_columns: |
194
|
|
|
if p.foreign_keys: |
195
|
|
|
obj_changed = True |
196
|
|
|
break |
197
|
|
|
if obj_changed is True: |
198
|
|
|
break |
199
|
|
|
|
200
|
|
|
if not obj_changed and not deleted: |
201
|
|
|
return |
202
|
|
|
|
203
|
|
|
attr['version'] = obj.version |
204
|
|
|
hist = history_cls() |
205
|
|
|
for key, value in attr.items(): |
206
|
|
|
setattr(hist, key, value) |
207
|
|
|
session.add(hist) |
208
|
|
|
obj.version += 1 |
209
|
|
|
|
210
|
|
|
def versioned_session(session): |
211
|
|
|
@event.listens_for(session, 'before_flush') |
212
|
|
|
def before_flush(session, flush_context, instances): |
213
|
|
|
for obj in versioned_objects(session.dirty): |
214
|
|
|
create_version(obj, session) |
215
|
|
|
for obj in versioned_objects(session.deleted): |
216
|
|
|
create_version(obj, session, deleted=True) |
217
|
|
|
return session |
218
|
|
|
|