| 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 | |
|---|