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