1 | """A custom list that manages index/position information for its children. |
---|
2 | |
---|
3 | ``orderinglist`` is a custom list collection implementation for mapped |
---|
4 | relations that keeps an arbitrary "position" attribute on contained objects in |
---|
5 | sync with each object's position in the Python list. |
---|
6 | |
---|
7 | The collection acts just like a normal Python ``list``, with the added |
---|
8 | behavior that as you manipulate the list (via ``insert``, ``pop``, assignment, |
---|
9 | deletion, what have you), each of the objects it contains is updated as needed |
---|
10 | to reflect its position. This is very useful for managing ordered relations |
---|
11 | which have a user-defined, serialized order:: |
---|
12 | |
---|
13 | >>> from sqlalchemy import MetaData, Table, Column, Integer, String, ForeignKey |
---|
14 | >>> from sqlalchemy.orm import mapper, relation |
---|
15 | >>> from sqlalchemy.ext.orderinglist import ordering_list |
---|
16 | |
---|
17 | A simple model of users their "top 10" things:: |
---|
18 | |
---|
19 | >>> metadata = MetaData() |
---|
20 | >>> users = Table('users', metadata, |
---|
21 | ... Column('id', Integer, primary_key=True)) |
---|
22 | >>> blurbs = Table('user_top_ten_list', metadata, |
---|
23 | ... Column('id', Integer, primary_key=True), |
---|
24 | ... Column('user_id', Integer, ForeignKey('users.id')), |
---|
25 | ... Column('position', Integer), |
---|
26 | ... Column('blurb', String(80))) |
---|
27 | >>> class User(object): |
---|
28 | ... pass |
---|
29 | ... |
---|
30 | >>> class Blurb(object): |
---|
31 | ... def __init__(self, blurb): |
---|
32 | ... self.blurb = blurb |
---|
33 | ... |
---|
34 | >>> mapper(User, users, properties={ |
---|
35 | ... 'topten': relation(Blurb, collection_class=ordering_list('position'), |
---|
36 | ... order_by=[blurbs.c.position])}) |
---|
37 | <Mapper ...> |
---|
38 | >>> mapper(Blurb, blurbs) |
---|
39 | <Mapper ...> |
---|
40 | |
---|
41 | Acts just like a regular list:: |
---|
42 | |
---|
43 | >>> u = User() |
---|
44 | >>> u.topten.append(Blurb('Number one!')) |
---|
45 | >>> u.topten.append(Blurb('Number two!')) |
---|
46 | |
---|
47 | But the ``.position`` attibute is set automatically behind the scenes:: |
---|
48 | |
---|
49 | >>> assert [blurb.position for blurb in u.topten] == [0, 1] |
---|
50 | |
---|
51 | The objects will be renumbered automaticaly after any list-changing operation, |
---|
52 | for example an ``insert()``:: |
---|
53 | |
---|
54 | >>> u.topten.insert(1, Blurb('I am the new Number Two.')) |
---|
55 | >>> assert [blurb.position for blurb in u.topten] == [0, 1, 2] |
---|
56 | >>> assert u.topten[1].blurb == 'I am the new Number Two.' |
---|
57 | >>> assert u.topten[1].position == 1 |
---|
58 | |
---|
59 | Numbering and serialization are both highly configurable. See the docstrings |
---|
60 | in this module and the main SQLAlchemy documentation for more information and |
---|
61 | examples. |
---|
62 | |
---|
63 | The :class:`~sqlalchemy.ext.orderinglist.ordering_list` factory function is the |
---|
64 | ORM-compatible constructor for `OrderingList` instances. |
---|
65 | |
---|
66 | """ |
---|
67 | from sqlalchemy.orm.collections import collection |
---|
68 | from sqlalchemy import util |
---|
69 | |
---|
70 | __all__ = [ 'ordering_list' ] |
---|
71 | |
---|
72 | |
---|
73 | def ordering_list(attr, count_from=None, **kw): |
---|
74 | """Prepares an OrderingList factory for use in mapper definitions. |
---|
75 | |
---|
76 | Returns an object suitable for use as an argument to a Mapper relation's |
---|
77 | ``collection_class`` option. Arguments are: |
---|
78 | |
---|
79 | attr |
---|
80 | Name of the mapped attribute to use for storage and retrieval of |
---|
81 | ordering information |
---|
82 | |
---|
83 | count_from (optional) |
---|
84 | Set up an integer-based ordering, starting at ``count_from``. For |
---|
85 | example, ``ordering_list('pos', count_from=1)`` would create a 1-based |
---|
86 | list in SQL, storing the value in the 'pos' column. Ignored if |
---|
87 | ``ordering_func`` is supplied. |
---|
88 | |
---|
89 | Passes along any keyword arguments to ``OrderingList`` constructor. |
---|
90 | """ |
---|
91 | |
---|
92 | kw = _unsugar_count_from(count_from=count_from, **kw) |
---|
93 | return lambda: OrderingList(attr, **kw) |
---|
94 | |
---|
95 | # Ordering utility functions |
---|
96 | def count_from_0(index, collection): |
---|
97 | """Numbering function: consecutive integers starting at 0.""" |
---|
98 | |
---|
99 | return index |
---|
100 | |
---|
101 | def count_from_1(index, collection): |
---|
102 | """Numbering function: consecutive integers starting at 1.""" |
---|
103 | |
---|
104 | return index + 1 |
---|
105 | |
---|
106 | def count_from_n_factory(start): |
---|
107 | """Numbering function: consecutive integers starting at arbitrary start.""" |
---|
108 | |
---|
109 | def f(index, collection): |
---|
110 | return index + start |
---|
111 | try: |
---|
112 | f.__name__ = 'count_from_%i' % start |
---|
113 | except TypeError: |
---|
114 | pass |
---|
115 | return f |
---|
116 | |
---|
117 | def _unsugar_count_from(**kw): |
---|
118 | """Builds counting functions from keywrod arguments. |
---|
119 | |
---|
120 | Keyword argument filter, prepares a simple ``ordering_func`` from a |
---|
121 | ``count_from`` argument, otherwise passes ``ordering_func`` on unchanged. |
---|
122 | """ |
---|
123 | |
---|
124 | count_from = kw.pop('count_from', None) |
---|
125 | if kw.get('ordering_func', None) is None and count_from is not None: |
---|
126 | if count_from == 0: |
---|
127 | kw['ordering_func'] = count_from_0 |
---|
128 | elif count_from == 1: |
---|
129 | kw['ordering_func'] = count_from_1 |
---|
130 | else: |
---|
131 | kw['ordering_func'] = count_from_n_factory(count_from) |
---|
132 | return kw |
---|
133 | |
---|
134 | class OrderingList(list): |
---|
135 | """A custom list that manages position information for its children. |
---|
136 | |
---|
137 | See the module and __init__ documentation for more details. The |
---|
138 | ``ordering_list`` factory function is used to configure ``OrderingList`` |
---|
139 | collections in ``mapper`` relation definitions. |
---|
140 | |
---|
141 | """ |
---|
142 | |
---|
143 | def __init__(self, ordering_attr=None, ordering_func=None, |
---|
144 | reorder_on_append=False): |
---|
145 | """A custom list that manages position information for its children. |
---|
146 | |
---|
147 | ``OrderingList`` is a ``collection_class`` list implementation that |
---|
148 | syncs position in a Python list with a position attribute on the |
---|
149 | mapped objects. |
---|
150 | |
---|
151 | This implementation relies on the list starting in the proper order, |
---|
152 | so be **sure** to put an ``order_by`` on your relation. |
---|
153 | |
---|
154 | ordering_attr |
---|
155 | Name of the attribute that stores the object's order in the |
---|
156 | relation. |
---|
157 | |
---|
158 | ordering_func |
---|
159 | Optional. A function that maps the position in the Python list to a |
---|
160 | value to store in the ``ordering_attr``. Values returned are |
---|
161 | usually (but need not be!) integers. |
---|
162 | |
---|
163 | An ``ordering_func`` is called with two positional parameters: the |
---|
164 | index of the element in the list, and the list itself. |
---|
165 | |
---|
166 | If omitted, Python list indexes are used for the attribute values. |
---|
167 | Two basic pre-built numbering functions are provided in this module: |
---|
168 | ``count_from_0`` and ``count_from_1``. For more exotic examples |
---|
169 | like stepped numbering, alphabetical and Fibonacci numbering, see |
---|
170 | the unit tests. |
---|
171 | |
---|
172 | reorder_on_append |
---|
173 | Default False. When appending an object with an existing (non-None) |
---|
174 | ordering value, that value will be left untouched unless |
---|
175 | ``reorder_on_append`` is true. This is an optimization to avoid a |
---|
176 | variety of dangerous unexpected database writes. |
---|
177 | |
---|
178 | SQLAlchemy will add instances to the list via append() when your |
---|
179 | object loads. If for some reason the result set from the database |
---|
180 | skips a step in the ordering (say, row '1' is missing but you get |
---|
181 | '2', '3', and '4'), reorder_on_append=True would immediately |
---|
182 | renumber the items to '1', '2', '3'. If you have multiple sessions |
---|
183 | making changes, any of whom happen to load this collection even in |
---|
184 | passing, all of the sessions would try to "clean up" the numbering |
---|
185 | in their commits, possibly causing all but one to fail with a |
---|
186 | concurrent modification error. Spooky action at a distance. |
---|
187 | |
---|
188 | Recommend leaving this with the default of False, and just call |
---|
189 | ``reorder()`` if you're doing ``append()`` operations with |
---|
190 | previously ordered instances or when doing some housekeeping after |
---|
191 | manual sql operations. |
---|
192 | |
---|
193 | """ |
---|
194 | self.ordering_attr = ordering_attr |
---|
195 | if ordering_func is None: |
---|
196 | ordering_func = count_from_0 |
---|
197 | self.ordering_func = ordering_func |
---|
198 | self.reorder_on_append = reorder_on_append |
---|
199 | |
---|
200 | # More complex serialization schemes (multi column, e.g.) are possible by |
---|
201 | # subclassing and reimplementing these two methods. |
---|
202 | def _get_order_value(self, entity): |
---|
203 | return getattr(entity, self.ordering_attr) |
---|
204 | |
---|
205 | def _set_order_value(self, entity, value): |
---|
206 | setattr(entity, self.ordering_attr, value) |
---|
207 | |
---|
208 | def reorder(self): |
---|
209 | """Synchronize ordering for the entire collection. |
---|
210 | |
---|
211 | Sweeps through the list and ensures that each object has accurate |
---|
212 | ordering information set. |
---|
213 | |
---|
214 | """ |
---|
215 | for index, entity in enumerate(self): |
---|
216 | self._order_entity(index, entity, True) |
---|
217 | |
---|
218 | # As of 0.5, _reorder is no longer semi-private |
---|
219 | _reorder = reorder |
---|
220 | |
---|
221 | def _order_entity(self, index, entity, reorder=True): |
---|
222 | have = self._get_order_value(entity) |
---|
223 | |
---|
224 | # Don't disturb existing ordering if reorder is False |
---|
225 | if have is not None and not reorder: |
---|
226 | return |
---|
227 | |
---|
228 | should_be = self.ordering_func(index, self) |
---|
229 | if have != should_be: |
---|
230 | self._set_order_value(entity, should_be) |
---|
231 | |
---|
232 | def append(self, entity): |
---|
233 | super(OrderingList, self).append(entity) |
---|
234 | self._order_entity(len(self) - 1, entity, self.reorder_on_append) |
---|
235 | |
---|
236 | def _raw_append(self, entity): |
---|
237 | """Append without any ordering behavior.""" |
---|
238 | |
---|
239 | super(OrderingList, self).append(entity) |
---|
240 | _raw_append = collection.adds(1)(_raw_append) |
---|
241 | |
---|
242 | def insert(self, index, entity): |
---|
243 | self[index:index] = [entity] |
---|
244 | |
---|
245 | def remove(self, entity): |
---|
246 | super(OrderingList, self).remove(entity) |
---|
247 | self._reorder() |
---|
248 | |
---|
249 | def pop(self, index=-1): |
---|
250 | entity = super(OrderingList, self).pop(index) |
---|
251 | self._reorder() |
---|
252 | return entity |
---|
253 | |
---|
254 | def __setitem__(self, index, entity): |
---|
255 | if isinstance(index, slice): |
---|
256 | for i in range(index.start or 0, index.stop or 0, index.step or 1): |
---|
257 | self.__setitem__(i, entity[i]) |
---|
258 | else: |
---|
259 | self._order_entity(index, entity, True) |
---|
260 | super(OrderingList, self).__setitem__(index, entity) |
---|
261 | |
---|
262 | def __delitem__(self, index): |
---|
263 | super(OrderingList, self).__delitem__(index) |
---|
264 | self._reorder() |
---|
265 | |
---|
266 | def __setslice__(self, start, end, values): |
---|
267 | super(OrderingList, self).__setslice__(start, end, values) |
---|
268 | self._reorder() |
---|
269 | |
---|
270 | def __delslice__(self, start, end): |
---|
271 | super(OrderingList, self).__delslice__(start, end) |
---|
272 | self._reorder() |
---|
273 | |
---|
274 | for func_name, func in locals().items(): |
---|
275 | if (util.callable(func) and func.func_name == func_name and |
---|
276 | not func.__doc__ and hasattr(list, func_name)): |
---|
277 | func.__doc__ = getattr(list, func_name).__doc__ |
---|
278 | del func_name, func |
---|
279 | |
---|
280 | if __name__ == '__main__': |
---|
281 | import doctest |
---|
282 | doctest.testmod(optionflags=doctest.ELLIPSIS) |
---|
283 | |
---|