1from copy import deepcopy
2
3
4class NEWVALUE(object):
5    # A marker for new data added.
6    pass
7
8
9class Item(object):
10    """
11    An object representing the item data within a DynamoDB table.
12
13    An item is largely schema-free, meaning it can contain any data. The only
14    limitation is that it must have data for the fields in the ``Table``'s
15    schema.
16
17    This object presents a dictionary-like interface for accessing/storing
18    data. It also tries to intelligently track how data has changed throughout
19    the life of the instance, to be as efficient as possible about updates.
20
21    Empty items, or items that have no data, are considered falsey.
22
23    """
24    def __init__(self, table, data=None, loaded=False):
25        """
26        Constructs an (unsaved) ``Item`` instance.
27
28        To persist the data in DynamoDB, you'll need to call the ``Item.save``
29        (or ``Item.partial_save``) on the instance.
30
31        Requires a ``table`` parameter, which should be a ``Table`` instance.
32        This is required, as DynamoDB's API is focus around all operations
33        being table-level. It's also for persisting schema around many objects.
34
35        Optionally accepts a ``data`` parameter, which should be a dictionary
36        of the fields & values of the item. Alternatively, an ``Item`` instance
37        may be provided from which to extract the data.
38
39        Optionally accepts a ``loaded`` parameter, which should be a boolean.
40        ``True`` if it was preexisting data loaded from DynamoDB, ``False`` if
41        it's new data from the user. Default is ``False``.
42
43        Example::
44
45            >>> users = Table('users')
46            >>> user = Item(users, data={
47            ...     'username': 'johndoe',
48            ...     'first_name': 'John',
49            ...     'date_joined': 1248o61592,
50            ... })
51
52            # Change existing data.
53            >>> user['first_name'] = 'Johann'
54            # Add more data.
55            >>> user['last_name'] = 'Doe'
56            # Delete data.
57            >>> del user['date_joined']
58
59            # Iterate over all the data.
60            >>> for field, val in user.items():
61            ...     print "%s: %s" % (field, val)
62            username: johndoe
63            first_name: John
64            date_joined: 1248o61592
65
66        """
67        self.table = table
68        self._loaded = loaded
69        self._orig_data = {}
70        self._data = data
71        self._dynamizer = table._dynamizer
72
73        if isinstance(self._data, Item):
74            self._data = self._data._data
75        if self._data is None:
76            self._data = {}
77
78        if self._loaded:
79            self._orig_data = deepcopy(self._data)
80
81    def __getitem__(self, key):
82        return self._data.get(key, None)
83
84    def __setitem__(self, key, value):
85        self._data[key] = value
86
87    def __delitem__(self, key):
88        if not key in self._data:
89            return
90
91        del self._data[key]
92
93    def keys(self):
94        return self._data.keys()
95
96    def values(self):
97        return self._data.values()
98
99    def items(self):
100        return self._data.items()
101
102    def get(self, key, default=None):
103        return self._data.get(key, default)
104
105    def __iter__(self):
106        for key in self._data:
107            yield self._data[key]
108
109    def __contains__(self, key):
110        return key in self._data
111
112    def __bool__(self):
113        return bool(self._data)
114
115    __nonzero__ = __bool__
116
117    def _determine_alterations(self):
118        """
119        Checks the ``-orig_data`` against the ``_data`` to determine what
120        changes to the data are present.
121
122        Returns a dictionary containing the keys ``adds``, ``changes`` &
123        ``deletes``, containing the updated data.
124        """
125        alterations = {
126            'adds': {},
127            'changes': {},
128            'deletes': [],
129        }
130
131        orig_keys = set(self._orig_data.keys())
132        data_keys = set(self._data.keys())
133
134        # Run through keys we know are in both for changes.
135        for key in orig_keys.intersection(data_keys):
136            if self._data[key] != self._orig_data[key]:
137                if self._is_storable(self._data[key]):
138                    alterations['changes'][key] = self._data[key]
139                else:
140                    alterations['deletes'].append(key)
141
142        # Run through additions.
143        for key in data_keys.difference(orig_keys):
144            if self._is_storable(self._data[key]):
145                alterations['adds'][key] = self._data[key]
146
147        # Run through deletions.
148        for key in orig_keys.difference(data_keys):
149            alterations['deletes'].append(key)
150
151        return alterations
152
153    def needs_save(self, data=None):
154        """
155        Returns whether or not the data has changed on the ``Item``.
156
157        Optionally accepts a ``data`` argument, which accepts the output from
158        ``self._determine_alterations()`` if you've already called it. Typically
159        unnecessary to do. Default is ``None``.
160
161        Example:
162
163            >>> user.needs_save()
164            False
165            >>> user['first_name'] = 'Johann'
166            >>> user.needs_save()
167            True
168
169        """
170        if data is None:
171            data = self._determine_alterations()
172
173        needs_save = False
174
175        for kind in ['adds', 'changes', 'deletes']:
176            if len(data[kind]):
177                needs_save = True
178                break
179
180        return needs_save
181
182    def mark_clean(self):
183        """
184        Marks an ``Item`` instance as no longer needing to be saved.
185
186        Example:
187
188            >>> user.needs_save()
189            False
190            >>> user['first_name'] = 'Johann'
191            >>> user.needs_save()
192            True
193            >>> user.mark_clean()
194            >>> user.needs_save()
195            False
196
197        """
198        self._orig_data = deepcopy(self._data)
199
200    def mark_dirty(self):
201        """
202        DEPRECATED: Marks an ``Item`` instance as needing to be saved.
203
204        This method is no longer necessary, as the state tracking on ``Item``
205        has been improved to automatically detect proper state.
206        """
207        return
208
209    def load(self, data):
210        """
211        This is only useful when being handed raw data from DynamoDB directly.
212        If you have a Python datastructure already, use the ``__init__`` or
213        manually set the data instead.
214
215        Largely internal, unless you know what you're doing or are trying to
216        mix the low-level & high-level APIs.
217        """
218        self._data = {}
219
220        for field_name, field_value in data.get('Item', {}).items():
221            self[field_name] = self._dynamizer.decode(field_value)
222
223        self._loaded = True
224        self._orig_data = deepcopy(self._data)
225
226    def get_keys(self):
227        """
228        Returns a Python-style dict of the keys/values.
229
230        Largely internal.
231        """
232        key_fields = self.table.get_key_fields()
233        key_data = {}
234
235        for key in key_fields:
236            key_data[key] = self[key]
237
238        return key_data
239
240    def get_raw_keys(self):
241        """
242        Returns a DynamoDB-style dict of the keys/values.
243
244        Largely internal.
245        """
246        raw_key_data = {}
247
248        for key, value in self.get_keys().items():
249            raw_key_data[key] = self._dynamizer.encode(value)
250
251        return raw_key_data
252
253    def build_expects(self, fields=None):
254        """
255        Builds up a list of expecations to hand off to DynamoDB on save.
256
257        Largely internal.
258        """
259        expects = {}
260
261        if fields is None:
262            fields = list(self._data.keys()) + list(self._orig_data.keys())
263
264        # Only uniques.
265        fields = set(fields)
266
267        for key in fields:
268            expects[key] = {
269                'Exists': True,
270            }
271            value = None
272
273            # Check for invalid keys.
274            if not key in self._orig_data and not key in self._data:
275                raise ValueError("Unknown key %s provided." % key)
276
277            # States:
278            # * New field (only in _data)
279            # * Unchanged field (in both _data & _orig_data, same data)
280            # * Modified field (in both _data & _orig_data, different data)
281            # * Deleted field (only in _orig_data)
282            orig_value = self._orig_data.get(key, NEWVALUE)
283            current_value = self._data.get(key, NEWVALUE)
284
285            if orig_value == current_value:
286                # Existing field unchanged.
287                value = current_value
288            else:
289                if key in self._data:
290                    if not key in self._orig_data:
291                        # New field.
292                        expects[key]['Exists'] = False
293                    else:
294                        # Existing field modified.
295                        value = orig_value
296                else:
297                   # Existing field deleted.
298                    value = orig_value
299
300            if value is not None:
301                expects[key]['Value'] = self._dynamizer.encode(value)
302
303        return expects
304
305    def _is_storable(self, value):
306        # We need to prevent ``None``, empty string & empty set from
307        # heading to DDB, but allow false-y values like 0 & False make it.
308        if not value:
309            if not value in (0, 0.0, False):
310                return False
311
312        return True
313
314    def prepare_full(self):
315        """
316        Runs through all fields & encodes them to be handed off to DynamoDB
317        as part of an ``save`` (``put_item``) call.
318
319        Largely internal.
320        """
321        # This doesn't save on it's own. Rather, we prepare the datastructure
322        # and hand-off to the table to handle creation/update.
323        final_data = {}
324
325        for key, value in self._data.items():
326            if not self._is_storable(value):
327                continue
328
329            final_data[key] = self._dynamizer.encode(value)
330
331        return final_data
332
333    def prepare_partial(self):
334        """
335        Runs through **ONLY** the changed/deleted fields & encodes them to be
336        handed off to DynamoDB as part of an ``partial_save`` (``update_item``)
337        call.
338
339        Largely internal.
340        """
341        # This doesn't save on it's own. Rather, we prepare the datastructure
342        # and hand-off to the table to handle creation/update.
343        final_data = {}
344        fields = set()
345        alterations = self._determine_alterations()
346
347        for key, value in alterations['adds'].items():
348            final_data[key] = {
349                'Action': 'PUT',
350                'Value': self._dynamizer.encode(self._data[key])
351            }
352            fields.add(key)
353
354        for key, value in alterations['changes'].items():
355            final_data[key] = {
356                'Action': 'PUT',
357                'Value': self._dynamizer.encode(self._data[key])
358            }
359            fields.add(key)
360
361        for key in alterations['deletes']:
362            final_data[key] = {
363                'Action': 'DELETE',
364            }
365            fields.add(key)
366
367        return final_data, fields
368
369    def partial_save(self):
370        """
371        Saves only the changed data to DynamoDB.
372
373        Extremely useful for high-volume/high-write data sets, this allows
374        you to update only a handful of fields rather than having to push
375        entire items. This prevents many accidental overwrite situations as
376        well as saves on the amount of data to transfer over the wire.
377
378        Returns ``True`` on success, ``False`` if no save was performed or
379        the write failed.
380
381        Example::
382
383            >>> user['last_name'] = 'Doh!'
384            # Only the last name field will be sent to DynamoDB.
385            >>> user.partial_save()
386
387        """
388        key = self.get_keys()
389        # Build a new dict of only the data we're changing.
390        final_data, fields = self.prepare_partial()
391
392        if not final_data:
393            return False
394
395        # Remove the key(s) from the ``final_data`` if present.
396        # They should only be present if this is a new item, in which
397        # case we shouldn't be sending as part of the data to update.
398        for fieldname, value in key.items():
399            if fieldname in final_data:
400                del final_data[fieldname]
401
402                try:
403                    # It's likely also in ``fields``, so remove it there too.
404                    fields.remove(fieldname)
405                except KeyError:
406                    pass
407
408        # Build expectations of only the fields we're planning to update.
409        expects = self.build_expects(fields=fields)
410        returned = self.table._update_item(key, final_data, expects=expects)
411        # Mark the object as clean.
412        self.mark_clean()
413        return returned
414
415    def save(self, overwrite=False):
416        """
417        Saves all data to DynamoDB.
418
419        By default, this attempts to ensure that none of the underlying
420        data has changed. If any fields have changed in between when the
421        ``Item`` was constructed & when it is saved, this call will fail so
422        as not to cause any data loss.
423
424        If you're sure possibly overwriting data is acceptable, you can pass
425        an ``overwrite=True``. If that's not acceptable, you may be able to use
426        ``Item.partial_save`` to only write the changed field data.
427
428        Optionally accepts an ``overwrite`` parameter, which should be a
429        boolean. If you provide ``True``, the item will be forcibly overwritten
430        within DynamoDB, even if another process changed the data in the
431        meantime. (Default: ``False``)
432
433        Returns ``True`` on success, ``False`` if no save was performed.
434
435        Example::
436
437            >>> user['last_name'] = 'Doh!'
438            # All data on the Item is sent to DynamoDB.
439            >>> user.save()
440
441            # If it fails, you can overwrite.
442            >>> user.save(overwrite=True)
443
444        """
445        if not self.needs_save() and not overwrite:
446            return False
447
448        final_data = self.prepare_full()
449        expects = None
450
451        if overwrite is False:
452            # Build expectations about *all* of the data.
453            expects = self.build_expects()
454
455        returned = self.table._put_item(final_data, expects=expects)
456        # Mark the object as clean.
457        self.mark_clean()
458        return returned
459
460    def delete(self):
461        """
462        Deletes the item's data to DynamoDB.
463
464        Returns ``True`` on success.
465
466        Example::
467
468            # Buh-bye now.
469            >>> user.delete()
470
471        """
472        key_data = self.get_keys()
473        return self.table.delete_item(**key_data)
474