Dynamojo 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dynamojo/__init__.py +30 -0
- dynamojo/base.py +543 -0
- dynamojo/boto.py +4 -0
- dynamojo/config.py +65 -0
- dynamojo/exceptions.py +30 -0
- dynamojo/index.py +152 -0
- dynamojo/utils.py +87 -0
- dynamojo-0.2.0.dist-info/METADATA +126 -0
- dynamojo-0.2.0.dist-info/RECORD +10 -0
- dynamojo-0.2.0.dist-info/WHEEL +4 -0
dynamojo/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from .base import DynamojoBase
|
|
2
|
+
from .config import DynamojoConfig, JoinedAttribute
|
|
3
|
+
from .index import (
|
|
4
|
+
get_indexes,
|
|
5
|
+
Gsi,
|
|
6
|
+
Index,
|
|
7
|
+
IndexList,
|
|
8
|
+
IndexMap,
|
|
9
|
+
Lsi,
|
|
10
|
+
Mutator,
|
|
11
|
+
TableIndex,
|
|
12
|
+
)
|
|
13
|
+
from .utils import Delta
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Delta",
|
|
18
|
+
"DynamojoBase",
|
|
19
|
+
"DynamojoConfig",
|
|
20
|
+
"get_indexes",
|
|
21
|
+
"Gsi",
|
|
22
|
+
"Index",
|
|
23
|
+
"IndexList",
|
|
24
|
+
"IndexMap",
|
|
25
|
+
"JoinedAttribute",
|
|
26
|
+
"Lsi",
|
|
27
|
+
"Mutator",
|
|
28
|
+
"TableIndex"
|
|
29
|
+
]
|
|
30
|
+
|
dynamojo/base.py
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from collections import UserDict
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from logging import getLogger
|
|
7
|
+
from typing import Any, Dict, List, TypeVar, Union
|
|
8
|
+
|
|
9
|
+
from boto3.dynamodb.conditions import (
|
|
10
|
+
AttributeBase,
|
|
11
|
+
ConditionBase,
|
|
12
|
+
ConditionExpressionBuilder,
|
|
13
|
+
Attr,
|
|
14
|
+
Key
|
|
15
|
+
)
|
|
16
|
+
from pydantic import BaseModel, PrivateAttr
|
|
17
|
+
|
|
18
|
+
from .boto import DYNAMOCLIENT
|
|
19
|
+
from .index import Index, Lsi
|
|
20
|
+
from .config import DynamojoConfig
|
|
21
|
+
from .exceptions import StaticAttributeError, IndexNotFoundError
|
|
22
|
+
from .utils import Delta, TYPE_SERIALIZER, TYPE_DESERIALIZER
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DynamojoBase(BaseModel):
|
|
26
|
+
"""A class to use as a base for modeling objects to store in Dynamodb. This class
|
|
27
|
+
is intended to be inherited by another class, which actually defines the model.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
#: All subclasses should override with their own config of the type `:class:dynamojo.config.DynamojoConfig`
|
|
31
|
+
_config: DynamojoConfig = PrivateAttr()
|
|
32
|
+
#: Original object
|
|
33
|
+
_original: DynamojoBase = PrivateAttr()
|
|
34
|
+
|
|
35
|
+
def __new__(cls: DynamojoModel, *_: List[Any], **__: Dict[Any, Any]) -> DynamojoModel:
|
|
36
|
+
if cls is DynamojoBase:
|
|
37
|
+
raise TypeError(f"{cls} must be subclassed")
|
|
38
|
+
return super().__new__(cls)
|
|
39
|
+
|
|
40
|
+
def __init__(self: DynamojoModel, **kwargs: Dict[str, Any]) -> None:
|
|
41
|
+
|
|
42
|
+
"""Initialize a new object with any required or optional attributes"""
|
|
43
|
+
super().__init__(**kwargs)
|
|
44
|
+
|
|
45
|
+
for attr in self._config.joined_attributes:
|
|
46
|
+
if attr.attribute in self.dict():
|
|
47
|
+
raise AttributeError(
|
|
48
|
+
f"Attribute '{attr}' cannot be declared as a member and a joined field"
|
|
49
|
+
)
|
|
50
|
+
for attr in self._config.__index_keys__:
|
|
51
|
+
if attr in self.dict():
|
|
52
|
+
raise AttributeError(
|
|
53
|
+
f"Attribute {attr} is part of an index and cannot be declared directly. Use IndexMap() and an alias instead"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# TODO: Flush out these mutators.
|
|
57
|
+
for k, v in kwargs.items():
|
|
58
|
+
if k in self._config.mutators:
|
|
59
|
+
kwargs[k] = self._mutate_attribute(k, v)
|
|
60
|
+
|
|
61
|
+
self._original = deepcopy(self)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def _deepdiff(self):
|
|
65
|
+
return Delta(new=self, old=self._original, deep=True)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def _diff(self):
|
|
69
|
+
return Delta(new=self, old=self._original)
|
|
70
|
+
|
|
71
|
+
def __getattribute__(self: DynamojoModel, name: str) -> Any:
|
|
72
|
+
if super().__getattribute__("_config").__joined_attributes__.get(name):
|
|
73
|
+
return self._generate_joined_attribute(name)
|
|
74
|
+
|
|
75
|
+
return super().__getattribute__(name)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def _has_changed(self):
|
|
79
|
+
return self._deepdiff.hasChanged
|
|
80
|
+
|
|
81
|
+
def __setattr__(self: DynamojoModel, field: str, val: Any) -> None:
|
|
82
|
+
# Mutations should happen first to allow for other dynamic fields to get their updated value
|
|
83
|
+
if field in self._config.mutators:
|
|
84
|
+
val = self._mutate_attribute(field, val)
|
|
85
|
+
|
|
86
|
+
if field in self._config.__joined_attributes__:
|
|
87
|
+
raise AttributeError(
|
|
88
|
+
f"Attribute '{field}' is a joined field and cannot be set directly"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if field in self._config.__index_keys__:
|
|
92
|
+
raise AttributeError(
|
|
93
|
+
f"Attribute '{field}' is an index key and cannot be set directly. Use IndexMap() to create an alias"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Static fields can only be set once
|
|
97
|
+
if (
|
|
98
|
+
field in self._config.static_attributes
|
|
99
|
+
and hasattr(self, field)
|
|
100
|
+
and self.__getattribute__(field) != val
|
|
101
|
+
):
|
|
102
|
+
raise StaticAttributeError(f"Attribute '{field}' is immutable.")
|
|
103
|
+
|
|
104
|
+
return super().__setattr__(field, val)
|
|
105
|
+
|
|
106
|
+
def _db_item(self) -> Dict[str, Any]:
|
|
107
|
+
"""
|
|
108
|
+
Returns the item exactly as it is in the database. If self._config.store_aliases is False
|
|
109
|
+
then those aliases will be omitted.
|
|
110
|
+
"""
|
|
111
|
+
item = {**self.dict(), **self.joined_attributes(), **self.index_attributes()}
|
|
112
|
+
if not self._config.store_aliases:
|
|
113
|
+
for attr in self._config.__index_aliases__.values():
|
|
114
|
+
if attr in item:
|
|
115
|
+
del item[attr]
|
|
116
|
+
return item
|
|
117
|
+
|
|
118
|
+
def _generate_joined_attribute(self: DynamojoModel, name: str) -> str:
|
|
119
|
+
"""
|
|
120
|
+
Takes attribute names defined in self._config.joined_attributes and stores them with the values
|
|
121
|
+
of the corresponding attributes concatenated by JoinedAttribute.separator.
|
|
122
|
+
"""
|
|
123
|
+
item = super().__getattribute__("dict")()
|
|
124
|
+
joiner = super().__getattribute__("_config").__joined_attributes__[name]
|
|
125
|
+
sources = joiner.fields
|
|
126
|
+
separator = joiner.separator
|
|
127
|
+
new_val = [item.get(source, "") for source in sources]
|
|
128
|
+
return separator.join(new_val)
|
|
129
|
+
|
|
130
|
+
@classmethod
|
|
131
|
+
def _get_index_from_attributes(
|
|
132
|
+
cls, partitionkey: str = None, sortkey: str = None
|
|
133
|
+
) -> Index:
|
|
134
|
+
"""
|
|
135
|
+
Returns an Index() object based on attributes being passed as arguments.
|
|
136
|
+
If multiple indexes match then the table index, if matched is returned first. If
|
|
137
|
+
not the table index then the first match in the list.
|
|
138
|
+
"""
|
|
139
|
+
for x in cls._config.index_maps:
|
|
140
|
+
if x.index.name == "table":
|
|
141
|
+
table_index_map = x
|
|
142
|
+
break
|
|
143
|
+
matches = {}
|
|
144
|
+
|
|
145
|
+
for mapping in cls._config.index_maps:
|
|
146
|
+
|
|
147
|
+
if isinstance(mapping.index, Lsi):
|
|
148
|
+
pk = table_index_map.partitionkey
|
|
149
|
+
else:
|
|
150
|
+
pk = mapping.partitionkey
|
|
151
|
+
|
|
152
|
+
if hasattr(mapping, "sortkey"):
|
|
153
|
+
sk = mapping.sortkey
|
|
154
|
+
else:
|
|
155
|
+
sk = None
|
|
156
|
+
|
|
157
|
+
if (
|
|
158
|
+
# If we only had one key specified it HAS to be the partition
|
|
159
|
+
sortkey is None
|
|
160
|
+
and partitionkey == pk
|
|
161
|
+
) or (
|
|
162
|
+
partitionkey is not None
|
|
163
|
+
and sortkey is not None
|
|
164
|
+
and (
|
|
165
|
+
pk == partitionkey
|
|
166
|
+
# Catch cases where our key is not composite (yuck!)
|
|
167
|
+
and (sk == sortkey or sk is None)
|
|
168
|
+
)
|
|
169
|
+
):
|
|
170
|
+
matches[mapping.index.name] = mapping.index
|
|
171
|
+
|
|
172
|
+
if not matches:
|
|
173
|
+
raise IndexNotFoundError(
|
|
174
|
+
"Could not find a suitable index. Either specify a valid index or change the Condition statement"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
return matches.get("table", list(matches.values())[0])
|
|
178
|
+
|
|
179
|
+
@classmethod
|
|
180
|
+
def _get_raw_condition_expression(
|
|
181
|
+
self,
|
|
182
|
+
exp: ConditionBase,
|
|
183
|
+
index: Union[Index, str] = None,
|
|
184
|
+
expression_type="KeyConditionExpression",
|
|
185
|
+
):
|
|
186
|
+
"""
|
|
187
|
+
Take a boto3.dynamodb.conditions.ConditionBase object and convert it to a dictionary suitable as the condition expression,
|
|
188
|
+
expression attribute names, and expression attribute values for an operation using the low-level dynamodb client.
|
|
189
|
+
This allows usage of boto3.dynamodb.conditions.Condition deconstruction to be used for the sake of automatically selecting
|
|
190
|
+
indexes. Placeholder prefixes are replaced so that attribute names and attribute values dicts can be merged together in queries
|
|
191
|
+
where multiple types of condition expressions are passed.
|
|
192
|
+
"""
|
|
193
|
+
is_key_condition = expression_type == "KeyConditionExpression"
|
|
194
|
+
raw_exp = ConditionExpressionBuilder().build_expression(
|
|
195
|
+
exp, is_key_condition=is_key_condition
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
if is_key_condition:
|
|
199
|
+
attribute_names = list(raw_exp.attribute_name_placeholders.values())
|
|
200
|
+
if len(attribute_names) == 1:
|
|
201
|
+
attribute_names.append(None)
|
|
202
|
+
|
|
203
|
+
if isinstance(index, str):
|
|
204
|
+
index = self.get_index_by_name(index)
|
|
205
|
+
|
|
206
|
+
if index is None:
|
|
207
|
+
index = self._get_index_from_attributes(*attribute_names)
|
|
208
|
+
|
|
209
|
+
for placeholder, attr in raw_exp.attribute_name_placeholders.items():
|
|
210
|
+
if attr == attribute_names[0]:
|
|
211
|
+
raw_exp.attribute_name_placeholders[
|
|
212
|
+
placeholder
|
|
213
|
+
] = index.partitionkey
|
|
214
|
+
if len(attribute_names) == 2 and attr == attribute_names[1]:
|
|
215
|
+
raw_exp.attribute_name_placeholders[placeholder] = index.sortkey
|
|
216
|
+
|
|
217
|
+
for k, v in raw_exp.attribute_value_placeholders.items():
|
|
218
|
+
raw_exp.attribute_value_placeholders[k] = TYPE_SERIALIZER.serialize(v)
|
|
219
|
+
|
|
220
|
+
opts = {
|
|
221
|
+
"ExpressionAttributeNames": {},
|
|
222
|
+
"ExpressionAttributeValues": {},
|
|
223
|
+
expression_type: raw_exp.condition_expression,
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
# We have to do the dance below to keep queries that use KeyConditionExpression
|
|
227
|
+
# and FilterExpressionfrom having their name/value placeholders clobber each other
|
|
228
|
+
# when the dicts are merged to for the full query args
|
|
229
|
+
original_name_prefix = "#n"
|
|
230
|
+
original_value_prefix = ":v"
|
|
231
|
+
|
|
232
|
+
if expression_type == "KeyConditionExpression":
|
|
233
|
+
new_name_prefix = "#key_name"
|
|
234
|
+
new_value_prefix = ":key_value"
|
|
235
|
+
if index.name != "table":
|
|
236
|
+
opts["IndexName"] = index.name
|
|
237
|
+
|
|
238
|
+
elif expression_type == "FilterExpression":
|
|
239
|
+
new_name_prefix = "#attribute_name"
|
|
240
|
+
new_value_prefix = ":attribute_value"
|
|
241
|
+
|
|
242
|
+
elif expression_type == "ConditionExpression":
|
|
243
|
+
new_name_prefix = "#condition_attribute_name"
|
|
244
|
+
new_value_prefix = ":condition_attribute_value"
|
|
245
|
+
|
|
246
|
+
else:
|
|
247
|
+
raise TypeError(
|
|
248
|
+
"Invalid Condition type. Must be one of KeyConditionExpression, ConditionExpression, or FilterExpression"
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
for key, val in raw_exp.attribute_name_placeholders.items():
|
|
252
|
+
new_key = key.replace(original_name_prefix, new_name_prefix)
|
|
253
|
+
opts["ExpressionAttributeNames"][new_key] = val
|
|
254
|
+
opts[expression_type] = opts[expression_type].replace(key, new_key)
|
|
255
|
+
|
|
256
|
+
for key, val in raw_exp.attribute_value_placeholders.items():
|
|
257
|
+
new_key = key.replace(original_value_prefix, new_value_prefix)
|
|
258
|
+
opts["ExpressionAttributeValues"][new_key] = val
|
|
259
|
+
opts[expression_type] = opts[expression_type].replace(key, new_key)
|
|
260
|
+
|
|
261
|
+
opts["TableName"] = self._config.table
|
|
262
|
+
return opts
|
|
263
|
+
|
|
264
|
+
@classmethod
|
|
265
|
+
def _construct_from_db(cls, item: Dict) -> DynamojoModel:
|
|
266
|
+
"""
|
|
267
|
+
Rehydrates an object from an item out of the DB.
|
|
268
|
+
"""
|
|
269
|
+
item = cls._deserialize_dynamo(item)
|
|
270
|
+
res = {}
|
|
271
|
+
|
|
272
|
+
for attr, val in item.items():
|
|
273
|
+
if not (
|
|
274
|
+
attr in cls._config.__index_keys__
|
|
275
|
+
or attr in cls._config.__joined_attributes__
|
|
276
|
+
):
|
|
277
|
+
res[attr] = val
|
|
278
|
+
|
|
279
|
+
if not cls._config.store_aliases:
|
|
280
|
+
for index, alias in cls._config.__index_aliases__.items():
|
|
281
|
+
res[alias] = item[index]
|
|
282
|
+
|
|
283
|
+
return cls.construct(**(res))
|
|
284
|
+
|
|
285
|
+
def delete(self) -> None:
|
|
286
|
+
"""
|
|
287
|
+
Deletes an item from the table
|
|
288
|
+
"""
|
|
289
|
+
key = {
|
|
290
|
+
self._config.indexes.table.partitionkey: self.__index_values__[
|
|
291
|
+
self._config.indexes.table.partitionkey
|
|
292
|
+
]
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if self._config.indexes.table.is_composite:
|
|
296
|
+
key[self._config.indexes.table.sortkey] = self.__index_values__[
|
|
297
|
+
self._config.indexes.table.sortkey
|
|
298
|
+
]
|
|
299
|
+
|
|
300
|
+
res = self._config.table.delete_item(Key=key)
|
|
301
|
+
|
|
302
|
+
return res
|
|
303
|
+
|
|
304
|
+
@staticmethod
|
|
305
|
+
def _deserialize_dynamo(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
306
|
+
"""
|
|
307
|
+
Deserializes the results from a low-level boto3 Dynamodb client query/get_item
|
|
308
|
+
into a standard dictionary.
|
|
309
|
+
"""
|
|
310
|
+
return {k: TYPE_DESERIALIZER.deserialize(v) for k, v in data.items()}
|
|
311
|
+
|
|
312
|
+
@classmethod
|
|
313
|
+
def fetch(cls, pk: str, sk: str = None, **kwargs: Dict[str, Any]) -> DynamojoModel:
|
|
314
|
+
"""
|
|
315
|
+
Returns a rehydrated object from the database
|
|
316
|
+
"""
|
|
317
|
+
|
|
318
|
+
key = {cls._config.indexes.table.partitionkey: pk}
|
|
319
|
+
|
|
320
|
+
if cls._config.indexes.table.sortkey:
|
|
321
|
+
key[cls._config.indexes.table.sortkey] = sk
|
|
322
|
+
|
|
323
|
+
serialized_key = {k: TYPE_SERIALIZER.serialize(v) for k, v in key.items()}
|
|
324
|
+
|
|
325
|
+
opts = {"Key": serialized_key, "TableName": cls._config.table, **kwargs}
|
|
326
|
+
|
|
327
|
+
res = DYNAMOCLIENT.get_item(**opts)
|
|
328
|
+
|
|
329
|
+
if item := res.get("Item"):
|
|
330
|
+
return cls._construct_from_db(item)
|
|
331
|
+
|
|
332
|
+
@classmethod
|
|
333
|
+
def get_index_by_name(cls, name: str) -> Index:
|
|
334
|
+
"""
|
|
335
|
+
Accepts a string as a name and returns an Index() object
|
|
336
|
+
"""
|
|
337
|
+
try:
|
|
338
|
+
return cls._config.indexes[name]
|
|
339
|
+
except KeyError:
|
|
340
|
+
raise IndexNotFoundError(f"Index {name} does not exist")
|
|
341
|
+
|
|
342
|
+
def index_attributes(self) -> Dict[str, Any]:
|
|
343
|
+
"""
|
|
344
|
+
Returns a dict containing index attributes as keys along with their set values
|
|
345
|
+
"""
|
|
346
|
+
indexes = {}
|
|
347
|
+
for mapping in self._config.index_maps:
|
|
348
|
+
if hasattr(mapping, "partitionkey"):
|
|
349
|
+
indexes[mapping.index.partitionkey] = self.__getattribute__(
|
|
350
|
+
mapping.partitionkey
|
|
351
|
+
)
|
|
352
|
+
if hasattr(mapping, "sortkey"):
|
|
353
|
+
indexes[mapping.index.sortkey] = self.__getattribute__(mapping.sortkey)
|
|
354
|
+
return indexes
|
|
355
|
+
|
|
356
|
+
def item(self) -> Dict[str, Any]:
|
|
357
|
+
"""
|
|
358
|
+
Returns a dict that contains declared attributes and attributes dynamically generated
|
|
359
|
+
by self._config.joined_attributes
|
|
360
|
+
"""
|
|
361
|
+
return {**self.dict(), **self.joined_attributes()}
|
|
362
|
+
|
|
363
|
+
def joined_attributes(self) -> Dict[str, str]:
|
|
364
|
+
"""
|
|
365
|
+
Returns a dict of attributes created dynamically by self._config.joined_attributes
|
|
366
|
+
"""
|
|
367
|
+
return {
|
|
368
|
+
attr: self.__getattribute__(attr) for attr in self._config.__joined_attributes__
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
@classmethod
|
|
372
|
+
def _mutate_attribute(cls, field: str, val: Any) -> Any:
|
|
373
|
+
"""
|
|
374
|
+
Returns the mutated value using the callable specified in cls._config.mutators for
|
|
375
|
+
an attribute.
|
|
376
|
+
"""
|
|
377
|
+
return super().__setattr__(
|
|
378
|
+
field, cls._config.mutators[field].callable(field, val, cls)
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
def _prepare_db_item(self):
|
|
382
|
+
"""
|
|
383
|
+
Serializes self.item() for storage in the database using the low-level dynamodb client.
|
|
384
|
+
"""
|
|
385
|
+
item = {
|
|
386
|
+
k: TYPE_SERIALIZER.serialize(v)
|
|
387
|
+
for k, v in self._db_item().items()
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if not self._config.store_aliases:
|
|
391
|
+
for alias in self._config.__index_aliases__.values():
|
|
392
|
+
if alias in item:
|
|
393
|
+
del item[alias]
|
|
394
|
+
|
|
395
|
+
return item
|
|
396
|
+
|
|
397
|
+
@classmethod
|
|
398
|
+
def query(
|
|
399
|
+
cls,
|
|
400
|
+
KeyConditionExpression: ConditionBase,
|
|
401
|
+
Index: Union[Index, str] = None,
|
|
402
|
+
FilterExpression: AttributeBase = None,
|
|
403
|
+
Limit: int = 1000,
|
|
404
|
+
ExclusiveStartKey: dict = None,
|
|
405
|
+
result_type: str = "standard",
|
|
406
|
+
**kwargs: Dict[str, Any],
|
|
407
|
+
) -> QueryResults:
|
|
408
|
+
"""
|
|
409
|
+
Runs a Dynamodb query using a condition from db.Index. The kwargs argument can be any
|
|
410
|
+
boto3.client("dynamodb").query() argument that is not explicitely defined in the signature.
|
|
411
|
+
"""
|
|
412
|
+
|
|
413
|
+
if result_type not in ("standard", "deserialized", "raw"):
|
|
414
|
+
raise ValueError("Argument 'result_type' must be one of standard, raw, or deserialized")
|
|
415
|
+
|
|
416
|
+
opts = {**kwargs, "Limit": Limit}
|
|
417
|
+
|
|
418
|
+
opts.update(
|
|
419
|
+
cls._get_raw_condition_expression(exp=KeyConditionExpression, index=Index)
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
if FilterExpression is not None:
|
|
423
|
+
filter_opts = cls._get_raw_condition_expression(
|
|
424
|
+
exp=FilterExpression, expression_type="FilterExpression"
|
|
425
|
+
)
|
|
426
|
+
opts["ExpressionAttributeNames"].update(
|
|
427
|
+
filter_opts.pop("ExpressionAttributeNames")
|
|
428
|
+
)
|
|
429
|
+
opts["ExpressionAttributeValues"].update(
|
|
430
|
+
filter_opts.pop("ExpressionAttributeValues")
|
|
431
|
+
)
|
|
432
|
+
opts.update(filter_opts)
|
|
433
|
+
|
|
434
|
+
if ExclusiveStartKey is not None:
|
|
435
|
+
opts["ExclusiveStartKey"] = ExclusiveStartKey
|
|
436
|
+
|
|
437
|
+
msg = (
|
|
438
|
+
f"Querying with index `{opts['IndexName']}`"
|
|
439
|
+
if opts.get("IndexName")
|
|
440
|
+
else "Querying with table index"
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
getLogger().info(msg)
|
|
444
|
+
|
|
445
|
+
res = DYNAMOCLIENT.query(**opts)
|
|
446
|
+
|
|
447
|
+
res["Items"] = [cls._construct_from_db(item) for item in res["Items"]]
|
|
448
|
+
return QueryResults(**res)
|
|
449
|
+
|
|
450
|
+
def save(
|
|
451
|
+
self, ConditionExpression: ConditionBase = None, fail_on_exists: bool = True, **kwargs: Dict[str, Any]
|
|
452
|
+
) -> None:
|
|
453
|
+
"""
|
|
454
|
+
Stores our item in Dynamodb
|
|
455
|
+
"""
|
|
456
|
+
|
|
457
|
+
table_pk = self._config.indexes.table.partitionkey
|
|
458
|
+
table_sk = self._config.indexes.table.sortkey
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
item = self._prepare_db_item()
|
|
462
|
+
|
|
463
|
+
opts = {"TableName": self._config.table, "Item": item, **kwargs}
|
|
464
|
+
|
|
465
|
+
if ConditionExpression:
|
|
466
|
+
exp = self._get_raw_condition_expression(
|
|
467
|
+
ConditionExpression, expression_type="ConditionExpression"
|
|
468
|
+
)
|
|
469
|
+
opts.update(exp)
|
|
470
|
+
|
|
471
|
+
if fail_on_exists:
|
|
472
|
+
sk_expression = f"attribute_not_exists({table_sk})"
|
|
473
|
+
pk_expression = f"attribute_not_exists({table_pk})"
|
|
474
|
+
fail_expression = f"({pk_expression} AND {sk_expression}) " if table_sk is not None else pk_expression
|
|
475
|
+
if ConditionExpression:
|
|
476
|
+
opts["ConditionExpression"] = f"{fail_expression} AND {opts['ConditionExpression']}"
|
|
477
|
+
else:
|
|
478
|
+
opts["ConditionExpression"] = fail_expression
|
|
479
|
+
|
|
480
|
+
return DYNAMOCLIENT.put_item(**opts)
|
|
481
|
+
|
|
482
|
+
def update(self, **opts):
|
|
483
|
+
diff = self._deepdiff
|
|
484
|
+
|
|
485
|
+
if not diff.hasChanged:
|
|
486
|
+
return None
|
|
487
|
+
|
|
488
|
+
pk_name = self._config.indexes.table.partitionkey
|
|
489
|
+
sk_name = self._config.indexes.table.sortkey
|
|
490
|
+
if (
|
|
491
|
+
pk_name in diff.keys
|
|
492
|
+
or sk_name in diff.keys
|
|
493
|
+
):
|
|
494
|
+
raise AttributeError("Cannot update table key attributes. Use `self.save()` instead.")
|
|
495
|
+
|
|
496
|
+
attribute_names = {}
|
|
497
|
+
attribute_values = {}
|
|
498
|
+
set_statement_items = []
|
|
499
|
+
del_statement_items = []
|
|
500
|
+
set_items = {**diff.added, **diff.changed}
|
|
501
|
+
key = {
|
|
502
|
+
pk_name: TYPE_SERIALIZER.serialize(self._db_item()[pk_name])
|
|
503
|
+
}
|
|
504
|
+
if sk_name is not None:
|
|
505
|
+
key[sk_name] = TYPE_SERIALIZER.serialize(self._db_item()[sk_name])
|
|
506
|
+
|
|
507
|
+
for attr, val in self._db_item().items():
|
|
508
|
+
if attr in diff.keys:
|
|
509
|
+
attribute_names[f"#{attr}"] = attr
|
|
510
|
+
attribute_values[f":{attr}"] = val
|
|
511
|
+
if attr in set_items.keys():
|
|
512
|
+
statement = f"#{attr} = :{attr}"
|
|
513
|
+
set_statement_items.append(statement)
|
|
514
|
+
else:
|
|
515
|
+
del_statement_items.append(statement)
|
|
516
|
+
|
|
517
|
+
set_statement = f"SET {', '.join(set_statement_items)}" if set_statement_items else ""
|
|
518
|
+
del_statement = f"REMOVE {', '.join(del_statement_items)}" if del_statement_items else ""
|
|
519
|
+
|
|
520
|
+
opts["TableName"] = self._config.table
|
|
521
|
+
opts["Key"] = key
|
|
522
|
+
opts["ExpressionAttributeNames"] = attribute_names
|
|
523
|
+
opts["ExpressionAttributeValues"] = {
|
|
524
|
+
k: TYPE_SERIALIZER.serialize(v)
|
|
525
|
+
for k, v in attribute_values.items()
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
opts["UpdateExpression"] = f"{set_statement} {del_statement}"
|
|
529
|
+
DYNAMOCLIENT.update_item(**opts)
|
|
530
|
+
return self
|
|
531
|
+
|
|
532
|
+
@dataclass
|
|
533
|
+
class QueryResults(UserDict):
|
|
534
|
+
Items: List[DynamojoModel]
|
|
535
|
+
Count: int
|
|
536
|
+
ResponseMetadata: Dict[str, Any]
|
|
537
|
+
ScannedCount: int
|
|
538
|
+
LastEvaluatedKey: Dict[str, Dict[str, Any]] = None
|
|
539
|
+
ConsumedCapacity: Dict[str, Any] = None
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
DynamojoModel = TypeVar("DynamojoModel", bound=DynamojoBase)
|
|
543
|
+
|
dynamojo/boto.py
ADDED
dynamojo/config.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from typing import Union, Callable, List, Dict
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, PrivateAttr
|
|
5
|
+
|
|
6
|
+
from .index import IndexList, IndexMap, Mutator
|
|
7
|
+
|
|
8
|
+
class JoinedAttribute(BaseModel):
|
|
9
|
+
attribute: str
|
|
10
|
+
fields: List[str]
|
|
11
|
+
separator: str = "~"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DynamojoConfig(BaseModel):
|
|
15
|
+
# A dictionary in the form of {"<target attribute>": ["source_att_one", "source_att_two"]} where <target_attribute> will
|
|
16
|
+
# automatically be overwritten by the attributes of it's corresponding list being joined with '~'. This is useful for creating
|
|
17
|
+
# keys that can be queried over using Key().begins_with() or Key().between() by creating a means to filter based on the compounded
|
|
18
|
+
# attributes.
|
|
19
|
+
joined_attributes: List[JoinedAttribute]
|
|
20
|
+
__joined_attributes__: Dict = {}
|
|
21
|
+
|
|
22
|
+
# A list of database `Index` objects from `dynamojo.indexes.get_indexes()``
|
|
23
|
+
indexes: IndexList
|
|
24
|
+
|
|
25
|
+
# A list of `IndexMap` objects used to map arbitrary fields into index attributes
|
|
26
|
+
index_maps: List[IndexMap] = []
|
|
27
|
+
|
|
28
|
+
static_attributes: List[str] = []
|
|
29
|
+
|
|
30
|
+
# A Dynamodb table name
|
|
31
|
+
table: str
|
|
32
|
+
|
|
33
|
+
mutators: List[Mutator] = []
|
|
34
|
+
|
|
35
|
+
# If set to False then attributes that are aliases of indexes will be stripped
|
|
36
|
+
# out before storing in the db
|
|
37
|
+
store_aliases: bool = True
|
|
38
|
+
|
|
39
|
+
underscore_attrs_are_private: bool = True
|
|
40
|
+
|
|
41
|
+
# Dict of `index key: alias name`
|
|
42
|
+
__index_aliases__: dict = PrivateAttr(default={})
|
|
43
|
+
|
|
44
|
+
__index_keys__: List[str] = PrivateAttr(default={})
|
|
45
|
+
|
|
46
|
+
class Config:
|
|
47
|
+
underscore_attrs_are_private: bool = True
|
|
48
|
+
arbitrary_types_allowed = True
|
|
49
|
+
extra = "allow"
|
|
50
|
+
|
|
51
|
+
def __init__(self, **kwargs):
|
|
52
|
+
super().__init__(**kwargs)
|
|
53
|
+
|
|
54
|
+
for attr in self.joined_attributes:
|
|
55
|
+
self.__joined_attributes__[attr.attribute] = attr
|
|
56
|
+
|
|
57
|
+
for index_map in self.index_maps:
|
|
58
|
+
if sk_att := index_map.sortkey:
|
|
59
|
+
self.__index_aliases__[index_map.index.sortkey] = sk_att
|
|
60
|
+
if getattr(index_map, "partitionkey", None):
|
|
61
|
+
self.__index_aliases__[
|
|
62
|
+
index_map.index.partitionkey
|
|
63
|
+
] = index_map.partitionkey
|
|
64
|
+
|
|
65
|
+
self.__index_keys__ = list(set(self.__index_aliases__.keys()))
|
dynamojo/exceptions.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
class DynamodbException(Exception):
|
|
2
|
+
pass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class RequiredAttributeError(DynamodbException):
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class StaticAttributeError(DynamodbException):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class UnknownAttributeError(DynamodbException):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ProtectedAttributeError(DynamodbException):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ItemNotFoundError(DynamodbException):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class NotAuthorized(DynamodbException):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class IndexNotFoundError(DynamodbException):
|
|
30
|
+
pass
|
dynamojo/index.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env python3.8
|
|
2
|
+
from collections import UserDict
|
|
3
|
+
from typing import List, Callable, Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from .boto import DYNAMOCLIENT
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Mutator(BaseModel):
|
|
11
|
+
source: str
|
|
12
|
+
callable: Callable[[str, Any, object], Any]
|
|
13
|
+
|
|
14
|
+
class Config:
|
|
15
|
+
frozen = True
|
|
16
|
+
arbitrary_types_allowed: True
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Index:
|
|
20
|
+
"""
|
|
21
|
+
Used as a factory for creating objects that can generate KeyConditionExpressions and abstract the
|
|
22
|
+
partion/sort key names from the developer using them.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
*,
|
|
28
|
+
name: str,
|
|
29
|
+
sortkey: str,
|
|
30
|
+
partitionkey: str = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
|
|
33
|
+
self.__partitionkey = partitionkey if partitionkey else None
|
|
34
|
+
self.__sortkey = sortkey if sortkey else None
|
|
35
|
+
self.__name = name
|
|
36
|
+
self.is_composite = partitionkey and sortkey
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def partitionkey(self) -> str:
|
|
40
|
+
"""The partition key of the index as Key(partition key name)"""
|
|
41
|
+
return self.__partitionkey
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def sortkey(self) -> str:
|
|
45
|
+
"""The sort key of the index as Key(sort key name)"""
|
|
46
|
+
return self.__sortkey
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def table_index(self) -> bool:
|
|
50
|
+
"""Whether or not this index is the table index"""
|
|
51
|
+
return isinstance(self, TableIndex)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def name(self) -> str:
|
|
55
|
+
"""Name of the index"""
|
|
56
|
+
return self.__name
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Gsi(Index):
|
|
60
|
+
def __init__(self, name: str, partitionkey: str, sortkey: str = None) -> None:
|
|
61
|
+
super().__init__(name=name, sortkey=sortkey, partitionkey=partitionkey)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Lsi(Index):
|
|
65
|
+
def __init__(self, name: str, sortkey: str) -> None:
|
|
66
|
+
super().__init__(name=name, sortkey=sortkey)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TableIndex(Index):
|
|
70
|
+
def __init__(self, name: str, partitionkey: str, sortkey: str = None) -> None:
|
|
71
|
+
super().__init__(name=name, partitionkey=partitionkey, sortkey=sortkey)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class IndexList(UserDict):
|
|
75
|
+
def __init__(self, *args: List[Index]) -> None:
|
|
76
|
+
super().__init__()
|
|
77
|
+
has_table = False
|
|
78
|
+
for index in args:
|
|
79
|
+
if not isinstance(index, Index):
|
|
80
|
+
raise TypeError("Invalid type for Index")
|
|
81
|
+
if isinstance(index, TableIndex):
|
|
82
|
+
if has_table:
|
|
83
|
+
raise ValueError("An IndexList object can only have one TableIndex")
|
|
84
|
+
has_table = True
|
|
85
|
+
|
|
86
|
+
super().__setattr__(index.name, index)
|
|
87
|
+
self.data[index.name] = index
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get_indexes(table_name: str) -> IndexList:
|
|
91
|
+
|
|
92
|
+
desc = DYNAMOCLIENT.describe_table(TableName=table_name)["Table"]
|
|
93
|
+
gsi_list = desc.get("GlobalSecondaryIndexes", [])
|
|
94
|
+
lsi_list = desc.get("LocalSecondaryIndexes", [])
|
|
95
|
+
|
|
96
|
+
table_list = [{"IndexName": "table", "KeySchema": desc["KeySchema"]}]
|
|
97
|
+
|
|
98
|
+
def build_indexes(index_type, index_list):
|
|
99
|
+
index_objects = []
|
|
100
|
+
for index in index_list:
|
|
101
|
+
args = {}
|
|
102
|
+
args["name"] = index["IndexName"]
|
|
103
|
+
|
|
104
|
+
for attr in index["KeySchema"]:
|
|
105
|
+
if attr["KeyType"] == "HASH" and index_type != Lsi:
|
|
106
|
+
args["partitionkey"] = attr["AttributeName"]
|
|
107
|
+
elif attr["KeyType"] == "RANGE":
|
|
108
|
+
args["sortkey"] = attr["AttributeName"]
|
|
109
|
+
|
|
110
|
+
index_objects.append(index_type(**args))
|
|
111
|
+
|
|
112
|
+
return index_objects
|
|
113
|
+
|
|
114
|
+
indexes = IndexList(
|
|
115
|
+
*[
|
|
116
|
+
*build_indexes(Gsi, gsi_list),
|
|
117
|
+
*build_indexes(Lsi, lsi_list),
|
|
118
|
+
*build_indexes(TableIndex, table_list),
|
|
119
|
+
]
|
|
120
|
+
)
|
|
121
|
+
return indexes
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class IndexMap:
|
|
125
|
+
index: Index
|
|
126
|
+
pk: str
|
|
127
|
+
sk: str = None
|
|
128
|
+
|
|
129
|
+
def __init__(
|
|
130
|
+
self, index: Index, partitionkey: str = None, sortkey: str = None
|
|
131
|
+
) -> None:
|
|
132
|
+
if isinstance(index, Lsi) and partitionkey:
|
|
133
|
+
raise ValueError(
|
|
134
|
+
"Lsi indexes only specify a sort key and use the table's partition key"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
elif index.partitionkey and not partitionkey:
|
|
138
|
+
raise ValueError(f"Partition key required for index {index.name}")
|
|
139
|
+
|
|
140
|
+
if index.sortkey and not sortkey:
|
|
141
|
+
raise ValueError(f"Sort key required for index {index.name}")
|
|
142
|
+
|
|
143
|
+
if sortkey and not index.sortkey:
|
|
144
|
+
raise ValueError(f"Index {index.name} requires a sort key")
|
|
145
|
+
|
|
146
|
+
if partitionkey:
|
|
147
|
+
self.partitionkey = partitionkey
|
|
148
|
+
|
|
149
|
+
if sortkey:
|
|
150
|
+
self.sortkey = sortkey
|
|
151
|
+
|
|
152
|
+
self.index = index
|
dynamojo/utils.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from pydantic import BaseModel, PrivateAttr
|
|
3
|
+
from typing import (
|
|
4
|
+
TYPE_CHECKING,
|
|
5
|
+
Any,
|
|
6
|
+
Dict,
|
|
7
|
+
NamedTuple
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .base import DynamojoBase
|
|
13
|
+
else:
|
|
14
|
+
DynamojoBase = object
|
|
15
|
+
|
|
16
|
+
from boto3.dynamodb.types import TypeSerializer, TypeDeserializer
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
TYPE_SERIALIZER = TypeSerializer()
|
|
20
|
+
TYPE_DESERIALIZER = TypeDeserializer()
|
|
21
|
+
|
|
22
|
+
Change = NamedTuple("Change", [("old", Any), ("new", Any)])
|
|
23
|
+
Diff = NamedTuple("Diff", (("added", Dict[str, Any]), ("removed", Dict[str, Any]), ("changed", Dict[str, Any])))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Delta(BaseModel):
|
|
27
|
+
old: DynamojoBase = None
|
|
28
|
+
new: DynamojoBase = None
|
|
29
|
+
|
|
30
|
+
_deep: bool = PrivateAttr()
|
|
31
|
+
_old: Dict[str, Any] = PrivateAttr()
|
|
32
|
+
_new: Dict[str, Any] = PrivateAttr()
|
|
33
|
+
|
|
34
|
+
def __init__(self, deep=True, **kwargs):
|
|
35
|
+
self._deep = deep
|
|
36
|
+
super().__init__(**kwargs)
|
|
37
|
+
|
|
38
|
+
if deep:
|
|
39
|
+
self._old = self.old._db_item()
|
|
40
|
+
self._new = self.new._db_item()
|
|
41
|
+
else:
|
|
42
|
+
self._old = self.old.item()
|
|
43
|
+
self._new = self.new.item()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def delta(self) -> Diff:
|
|
48
|
+
diff = Diff({}, {}, {})
|
|
49
|
+
|
|
50
|
+
for key, val in self._old.items():
|
|
51
|
+
if key not in self._new:
|
|
52
|
+
diff.removed[key] = val
|
|
53
|
+
if key in self._new and val != self._new[key]:
|
|
54
|
+
diff.changed[key] = Change(
|
|
55
|
+
self._old[key],
|
|
56
|
+
self._new[key]
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
for key, val in self._new.items():
|
|
60
|
+
if key not in self._old:
|
|
61
|
+
diff.added[key] = val
|
|
62
|
+
|
|
63
|
+
return diff
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def added(self) -> Dict[str, Any]:
|
|
67
|
+
return self.delta.added
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def changed(self) -> Change:
|
|
71
|
+
return self.delta.changed
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def removed(self) -> Dict[str, Any]:
|
|
75
|
+
return self.delta.removed
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def hasChanged(self):
|
|
79
|
+
return not self._old == self._new
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def keys(self):
|
|
83
|
+
return {
|
|
84
|
+
**self.added,
|
|
85
|
+
**self.removed,
|
|
86
|
+
**self.changed
|
|
87
|
+
}.keys()
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: Dynamojo
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: A cli client for accessing AWS secrets
|
|
5
|
+
Home-page: https://github.com/mathewmoon/dynamojo
|
|
6
|
+
Author: Mathew Moon
|
|
7
|
+
Author-email: me@mathewmoon.net
|
|
8
|
+
Classifier: Programming Language :: Python :: 2
|
|
9
|
+
Classifier: Programming Language :: Python :: 2.7
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.4
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.5
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.6
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Requires-Dist: boto3 (>=1.34.56,<2.0.0)
|
|
21
|
+
Requires-Dist: pydantic (<2.0)
|
|
22
|
+
Project-URL: Repository, https://github.com/mathewmoon/dynamojo
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# Dynamojo
|
|
26
|
+
## Because one table is better than more
|
|
27
|
+
|
|
28
|
+
Dynamojo takes the concept of Dynamodb Single Table design and creates a modeling framework for it. This library is opinionated in the following ways:
|
|
29
|
+
- Indexes should be generic. They could mean different things for different types of items. **An index attribute shouldn't imply that it is always a date, color, etc**.
|
|
30
|
+
- When using generic indexes the attributes should shadow a human readable attribute. For instance if you have a partition key named "pk" that for items that represent users stores their userid, then there should also be an attribute named userid.
|
|
31
|
+
- When creating models for item types that will be stored in the database the developer should only have to worry about their access patterns in terms of the human readable attributes, not be in the weeds of the index design of the table. Mapping items to indexes should happen in code, not in the table definition itself
|
|
32
|
+
- Table and Global Secondary indexes should always define a sortkey. There is no reason not to. It's better to have it in cases where you don't need it than to need it and not have it.
|
|
33
|
+
|
|
34
|
+
Dynamojo is built on top of Pydantic with some bells and whistles:
|
|
35
|
+
- put, update, delete, and query db objects
|
|
36
|
+
- Dynamically map attributes to the index of your choice. EG: attribute "userId" automatically populates the partition key named "pk"
|
|
37
|
+
- Dynamically join attributes into another using a delimiter. For instance create a field that is `<userid>~<date>~<action>` to use as a sort key for fast queries
|
|
38
|
+
- Mutate attributes when set
|
|
39
|
+
- Create models that subclass other models. A common pattern is to define a base class for your project that has a baseline of methods that you will need other than db operations. Different item types would then be created as models that are subclasses from the base class. See test.py
|
|
40
|
+
- Flag attributes as immutable so they can't be modified once set
|
|
41
|
+
- Use all of the features of `put_item()`, `query()`, `delete()`, and `update()` that you normally could with `boto3.client("dynamodb")`
|
|
42
|
+
|
|
43
|
+
### Limitations:
|
|
44
|
+
- Dynamojo doesn't do scans because scans are dumb. I will die on the hill of defending that statement.
|
|
45
|
+
- If you have so much data that replicating indexed data into human readable columns is too expensive then this library may not be for you. But if you have that much data you should have a staff of engineers that can write your own library.
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
### See test.py for examples
|
|
50
|
+
|
|
51
|
+
This library is very opinionated about how the table's indexes should be structured. Below is Terraform that shows the
|
|
52
|
+
correct way to set up the table. Index keys are never referenced directly when using the table. Rely on IndexMap for that.
|
|
53
|
+
Since LSI's can only be created at table creation time, and all indexes cost nothing if not used, we go ahead and create
|
|
54
|
+
all of the indexes that AWS will allow us to when the table is created.
|
|
55
|
+
|
|
56
|
+
```hcl
|
|
57
|
+
resource "aws_dynamodb_table" "test_table" {
|
|
58
|
+
name = "test-dynamojo"
|
|
59
|
+
hash_key = "pk"
|
|
60
|
+
range_key = "sk"
|
|
61
|
+
billing_mode = "PAY_PER_REQUEST"
|
|
62
|
+
|
|
63
|
+
# LSI attributes
|
|
64
|
+
dynamic "attribute" {
|
|
65
|
+
for_each = range(5)
|
|
66
|
+
|
|
67
|
+
content {
|
|
68
|
+
name = "lsi${attribute.value}_sk"
|
|
69
|
+
type = "S"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# GSI pk attributes
|
|
74
|
+
dynamic "attribute" {
|
|
75
|
+
for_each = range(20)
|
|
76
|
+
|
|
77
|
+
content {
|
|
78
|
+
name = "gsi${attribute.value}_pk"
|
|
79
|
+
type = "S"
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# GSI sk attributes
|
|
84
|
+
dynamic "attribute" {
|
|
85
|
+
for_each = range(20)
|
|
86
|
+
|
|
87
|
+
content {
|
|
88
|
+
name = "gsi${attribute.value}_sk"
|
|
89
|
+
type = "S"
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
attribute {
|
|
94
|
+
name = "pk"
|
|
95
|
+
type = "S"
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
attribute {
|
|
99
|
+
name = "sk"
|
|
100
|
+
type = "S"
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# GSI's
|
|
104
|
+
dynamic "global_secondary_index" {
|
|
105
|
+
for_each = range(20)
|
|
106
|
+
|
|
107
|
+
content {
|
|
108
|
+
name = "gsi${global_secondary_index.value}"
|
|
109
|
+
hash_key = "gsi${global_secondary_index.value}_pk"
|
|
110
|
+
range_key = "gsi${global_secondary_index.value}_sk"
|
|
111
|
+
projection_type = "ALL"
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
# LSI's
|
|
116
|
+
dynamic "local_secondary_index" {
|
|
117
|
+
for_each = range(5)
|
|
118
|
+
|
|
119
|
+
content {
|
|
120
|
+
name = "lsi${local_secondary_index.value}"
|
|
121
|
+
range_key = "lsi${local_secondary_index.value}_sk"
|
|
122
|
+
projection_type = "ALL"
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
dynamojo/__init__.py,sha256=bILBw1CxIisxMiMPhNF_m6PxGA9hYwam7MKIed2Pljg,449
|
|
2
|
+
dynamojo/base.py,sha256=ImPBdgcVIEojoJfvjgwnobn9rAtsNLmPa3CJ96oZnyM,19880
|
|
3
|
+
dynamojo/boto.py,sha256=VtrXAHyts0y4nCI7fo8BuowQE9qCoVZ9KJ1ZHXvmysQ,89
|
|
4
|
+
dynamojo/config.py,sha256=dFfMJpYUQ4gRPvsS_EVpgoEPmRz0UglO9wwTMlfx2aE,2212
|
|
5
|
+
dynamojo/exceptions.py,sha256=PL9LkviDckaBZOQUq-2tYG4hPyfHr623eK9UjnICMM0,445
|
|
6
|
+
dynamojo/index.py,sha256=lV8GIWyHBFQh6zMZZxVJPHOBRi_ue2XqqPwq97Wc_zo,4406
|
|
7
|
+
dynamojo/utils.py,sha256=QzsNIdVzfOd-Y8Zn4Zti07a-i71WNOup-v_ZCNsJRwk,1882
|
|
8
|
+
dynamojo-0.2.0.dist-info/METADATA,sha256=JhAXHv-lBAu8AdqJKQ08PY-amdBfSoMFXsJf2oqw700,5159
|
|
9
|
+
dynamojo-0.2.0.dist-info/WHEEL,sha256=IrRNNNJ-uuL1ggO5qMvT1GGhQVdQU54d6ZpYqEZfEWo,92
|
|
10
|
+
dynamojo-0.2.0.dist-info/RECORD,,
|