nuql 0.0.1__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.
- nuql/__init__.py +3 -0
- nuql/api/__init__.py +13 -0
- nuql/api/adapter.py +34 -0
- nuql/api/batch_get/__init__.py +2 -0
- nuql/api/batch_get/batch_get.py +40 -0
- nuql/api/batch_get/queue.py +120 -0
- nuql/api/batch_write.py +99 -0
- nuql/api/condition_check.py +39 -0
- nuql/api/create.py +25 -0
- nuql/api/delete.py +88 -0
- nuql/api/get.py +30 -0
- nuql/api/put_item.py +112 -0
- nuql/api/put_update.py +25 -0
- nuql/api/query/__init__.py +4 -0
- nuql/api/query/condition.py +157 -0
- nuql/api/query/condition_builder.py +211 -0
- nuql/api/query/key_condition.py +200 -0
- nuql/api/query/query.py +166 -0
- nuql/api/transaction.py +145 -0
- nuql/api/update/__init__.py +3 -0
- nuql/api/update/expression_builder.py +33 -0
- nuql/api/update/update_item.py +139 -0
- nuql/api/update/utils.py +126 -0
- nuql/api/upsert.py +32 -0
- nuql/client.py +88 -0
- nuql/connection.py +43 -0
- nuql/exceptions.py +66 -0
- nuql/fields/__init__.py +11 -0
- nuql/fields/boolean.py +29 -0
- nuql/fields/datetime.py +49 -0
- nuql/fields/datetime_timestamp.py +45 -0
- nuql/fields/float.py +40 -0
- nuql/fields/integer.py +40 -0
- nuql/fields/key.py +207 -0
- nuql/fields/list.py +90 -0
- nuql/fields/map.py +67 -0
- nuql/fields/string.py +184 -0
- nuql/fields/ulid.py +39 -0
- nuql/fields/uuid.py +42 -0
- nuql/generators/__init__.py +3 -0
- nuql/generators/datetime.py +37 -0
- nuql/generators/ulid.py +10 -0
- nuql/generators/uuid.py +19 -0
- nuql/resources/__init__.py +4 -0
- nuql/resources/fields/__init__.py +3 -0
- nuql/resources/fields/field.py +153 -0
- nuql/resources/fields/field_map.py +85 -0
- nuql/resources/fields/value.py +5 -0
- nuql/resources/records/__init__.py +3 -0
- nuql/resources/records/projections.py +49 -0
- nuql/resources/records/serialiser.py +144 -0
- nuql/resources/records/validator.py +48 -0
- nuql/resources/tables/__init__.py +2 -0
- nuql/resources/tables/indexes.py +140 -0
- nuql/resources/tables/table.py +151 -0
- nuql/resources/utils/__init__.py +2 -0
- nuql/resources/utils/dict.py +21 -0
- nuql/resources/utils/validators.py +165 -0
- nuql/types/__init__.py +3 -0
- nuql/types/config.py +27 -0
- nuql/types/fields.py +27 -0
- nuql/types/serialisation.py +10 -0
- nuql-0.0.1.dist-info/METADATA +12 -0
- nuql-0.0.1.dist-info/RECORD +65 -0
- nuql-0.0.1.dist-info/WHEEL +4 -0
nuql/api/query/query.py
ADDED
@@ -0,0 +1,166 @@
|
|
1
|
+
__all__ = ['Query']
|
2
|
+
|
3
|
+
from typing import Any, Dict, Optional
|
4
|
+
|
5
|
+
from botocore.exceptions import ClientError
|
6
|
+
|
7
|
+
import nuql
|
8
|
+
from nuql import types, api, resources
|
9
|
+
|
10
|
+
|
11
|
+
class Query(api.Boto3Adapter):
|
12
|
+
def prepare_client_args(
|
13
|
+
self,
|
14
|
+
key_condition: Dict[str, Any] | None = None,
|
15
|
+
condition: Dict[str, Any] | None = None,
|
16
|
+
index_name: str = 'primary',
|
17
|
+
limit: int | None = None,
|
18
|
+
scan_index_forward: bool = True,
|
19
|
+
exclusive_start_key: Dict[str, Any] | None = None,
|
20
|
+
consistent_read: bool = False,
|
21
|
+
) -> Dict[str, Any]:
|
22
|
+
"""
|
23
|
+
Prepares args for performing a query against the table (client API).
|
24
|
+
|
25
|
+
:param key_condition: Key condition expression as a dict.
|
26
|
+
:param condition: Filter condition expression as a dict.
|
27
|
+
:param index_name: Index to perform query against.
|
28
|
+
:param limit: Number of items to retrieve.
|
29
|
+
:param scan_index_forward: Direction of scan.
|
30
|
+
:param exclusive_start_key: Exclusive start key.
|
31
|
+
:param consistent_read: Perform query as a consistent read.
|
32
|
+
:return: Query result.
|
33
|
+
"""
|
34
|
+
# Key condition is parsed from a dict and validated
|
35
|
+
key_condition = api.KeyCondition(self.table, key_condition, index_name)
|
36
|
+
|
37
|
+
# Filter condition is parsed from a string and validated
|
38
|
+
resources.validate_condition_dict(condition)
|
39
|
+
filter_condition = api.Condition(
|
40
|
+
table=self.table,
|
41
|
+
condition=condition,
|
42
|
+
condition_type='FilterExpression'
|
43
|
+
)
|
44
|
+
|
45
|
+
return {
|
46
|
+
**key_condition.client_args,
|
47
|
+
**filter_condition.client_args,
|
48
|
+
'ScanIndexForward': scan_index_forward,
|
49
|
+
'ConsistentRead': consistent_read,
|
50
|
+
}
|
51
|
+
|
52
|
+
def prepare_args(
|
53
|
+
self,
|
54
|
+
key_condition: Dict[str, Any] | None = None,
|
55
|
+
condition: Dict[str, Any] | None = None,
|
56
|
+
index_name: str = 'primary',
|
57
|
+
limit: int | None = None,
|
58
|
+
scan_index_forward: bool = True,
|
59
|
+
exclusive_start_key: Dict[str, Any] | None = None,
|
60
|
+
consistent_read: bool = False,
|
61
|
+
) -> Dict[str, Any]:
|
62
|
+
"""
|
63
|
+
Prepares args for performing a query against the table (resource API).
|
64
|
+
|
65
|
+
:param key_condition: Key condition expression as a dict.
|
66
|
+
:param condition: Filter condition expression as a dict.
|
67
|
+
:param index_name: Index to perform query against.
|
68
|
+
:param limit: Number of items to retrieve.
|
69
|
+
:param scan_index_forward: Direction of scan.
|
70
|
+
:param exclusive_start_key: Exclusive start key.
|
71
|
+
:param consistent_read: Perform query as a consistent read.
|
72
|
+
:return: Query result.
|
73
|
+
"""
|
74
|
+
# Key condition is parsed from a dict and validated
|
75
|
+
key_condition = api.KeyCondition(self.table, key_condition, index_name)
|
76
|
+
|
77
|
+
# Filter condition is parsed from a string and validated
|
78
|
+
resources.validate_condition_dict(condition)
|
79
|
+
filter_condition = api.Condition(
|
80
|
+
table=self.table,
|
81
|
+
condition=condition,
|
82
|
+
condition_type='FilterExpression'
|
83
|
+
)
|
84
|
+
|
85
|
+
return {
|
86
|
+
**key_condition.resource_args,
|
87
|
+
**filter_condition.resource_args,
|
88
|
+
'ScanIndexForward': scan_index_forward,
|
89
|
+
'ConsistentRead': consistent_read,
|
90
|
+
}
|
91
|
+
|
92
|
+
def invoke_sync(
|
93
|
+
self,
|
94
|
+
key_condition: Dict[str, Any] | None = None,
|
95
|
+
condition: Dict[str, Any] | None = None,
|
96
|
+
index_name: str = 'primary',
|
97
|
+
limit: int | None = None,
|
98
|
+
scan_index_forward: bool = True,
|
99
|
+
exclusive_start_key: Dict[str, Any] | None = None,
|
100
|
+
consistent_read: bool = False,
|
101
|
+
) -> Dict[str, Any]:
|
102
|
+
"""
|
103
|
+
Synchronously invokes a query against the table.
|
104
|
+
|
105
|
+
:param key_condition: Key condition expression as a dict.
|
106
|
+
:param condition: Filter condition expression as a dict.
|
107
|
+
:param index_name: Index to perform query against.
|
108
|
+
:param limit: Number of items to retrieve.
|
109
|
+
:param scan_index_forward: Direction of scan.
|
110
|
+
:param exclusive_start_key: Exclusive start key.
|
111
|
+
:param consistent_read: Perform query as a consistent read.
|
112
|
+
:return: Query result.
|
113
|
+
"""
|
114
|
+
args = self.prepare_args(
|
115
|
+
key_condition=key_condition,
|
116
|
+
condition=condition,
|
117
|
+
index_name=index_name,
|
118
|
+
limit=limit,
|
119
|
+
scan_index_forward=scan_index_forward,
|
120
|
+
exclusive_start_key=exclusive_start_key,
|
121
|
+
consistent_read=consistent_read,
|
122
|
+
)
|
123
|
+
|
124
|
+
data = []
|
125
|
+
last_evaluated_key = exclusive_start_key
|
126
|
+
fulfilled = False
|
127
|
+
|
128
|
+
while not fulfilled:
|
129
|
+
# Subtract processed records from limit
|
130
|
+
if isinstance(limit, int):
|
131
|
+
args['Limit'] = limit - len(data)
|
132
|
+
|
133
|
+
# Break when limit is reached
|
134
|
+
if 'Limit' in args and args['Limit'] == 0:
|
135
|
+
break
|
136
|
+
|
137
|
+
# Pagination is achieved by using LEK as exclusive start key
|
138
|
+
if last_evaluated_key:
|
139
|
+
args['ExclusiveStartKey'] = last_evaluated_key
|
140
|
+
|
141
|
+
try:
|
142
|
+
response = self.connection.table.query(**args)
|
143
|
+
except ClientError as exc:
|
144
|
+
raise nuql.Boto3Error(exc, args)
|
145
|
+
|
146
|
+
data.extend(response.get('Items', []))
|
147
|
+
last_evaluated_key = response.get('LastEvaluatedKey')
|
148
|
+
|
149
|
+
if not last_evaluated_key:
|
150
|
+
fulfilled = True
|
151
|
+
|
152
|
+
# Deserialise the data
|
153
|
+
data = [self.table.serialiser.deserialise(item) for item in data]
|
154
|
+
|
155
|
+
output = {'items': [], 'last_evaluated_key': last_evaluated_key}
|
156
|
+
|
157
|
+
# Follow functionality on local/global indexes - batch gets all retrieved items
|
158
|
+
index = self.table.indexes.get_index(index_name) if index_name != 'primary' else self.table.indexes.primary
|
159
|
+
|
160
|
+
if 'follow' in index and index['follow'] is True:
|
161
|
+
batch_get = api.BatchGet(self.client, self.table)
|
162
|
+
output.update(batch_get.invoke_sync(data))
|
163
|
+
else:
|
164
|
+
output['items'] = data
|
165
|
+
|
166
|
+
return output
|
nuql/api/transaction.py
ADDED
@@ -0,0 +1,145 @@
|
|
1
|
+
__all__ = ['Transaction']
|
2
|
+
|
3
|
+
from typing import Dict, Any
|
4
|
+
|
5
|
+
from botocore.exceptions import ClientError
|
6
|
+
|
7
|
+
import nuql
|
8
|
+
from nuql import types, api, resources
|
9
|
+
|
10
|
+
|
11
|
+
# Maximum number of actions in a transaction
|
12
|
+
MAX_TRANSACTION_ACTIONS = 100
|
13
|
+
|
14
|
+
|
15
|
+
class Transaction:
|
16
|
+
def __init__(self, client: 'nuql.Nuql') -> None:
|
17
|
+
"""
|
18
|
+
Context manager for executing DynamoDB transactions.
|
19
|
+
|
20
|
+
:arg client: Nuql instance.
|
21
|
+
"""
|
22
|
+
self.client = client
|
23
|
+
|
24
|
+
self._actions = []
|
25
|
+
self._started = False
|
26
|
+
|
27
|
+
def __enter__(self):
|
28
|
+
"""Enter the context manager."""
|
29
|
+
self._actions = []
|
30
|
+
self._started = True
|
31
|
+
|
32
|
+
return self
|
33
|
+
|
34
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
35
|
+
"""Dispatch the transaction to DynamoDB."""
|
36
|
+
if not self._actions:
|
37
|
+
raise nuql.NuqlError(
|
38
|
+
code='TransactionError',
|
39
|
+
message='Transaction has no actions'
|
40
|
+
)
|
41
|
+
|
42
|
+
args = {'TransactItems': self._actions}
|
43
|
+
|
44
|
+
try:
|
45
|
+
self.client.connection.client.transact_write_items(**args)
|
46
|
+
except ClientError as exc:
|
47
|
+
raise nuql.Boto3Error(exc, args)
|
48
|
+
|
49
|
+
self._started = False
|
50
|
+
return False
|
51
|
+
|
52
|
+
def _validate(self) -> None:
|
53
|
+
"""Validates that the context manager has been started and maximum has not been exceeded."""
|
54
|
+
if not self._started:
|
55
|
+
raise nuql.NuqlError(
|
56
|
+
code='TransactionError',
|
57
|
+
message='Transaction context manager has not been started'
|
58
|
+
)
|
59
|
+
if len(self._actions) > MAX_TRANSACTION_ACTIONS:
|
60
|
+
raise nuql.NuqlError(
|
61
|
+
code='TransactionError',
|
62
|
+
message=f'Maximum number of actions exceeded in transaction (limit {MAX_TRANSACTION_ACTIONS})'
|
63
|
+
)
|
64
|
+
|
65
|
+
def create(
|
66
|
+
self,
|
67
|
+
table: 'resources.Table',
|
68
|
+
data: Dict[str, Any],
|
69
|
+
condition: Dict[str, Any] | None = None,
|
70
|
+
) -> None:
|
71
|
+
"""
|
72
|
+
Create a new item on a table as part of a transaction.
|
73
|
+
|
74
|
+
:arg table: Table instance.
|
75
|
+
:arg data: Data to create.
|
76
|
+
:param condition: Optional condition expression dict.
|
77
|
+
"""
|
78
|
+
self._validate()
|
79
|
+
|
80
|
+
create = api.Create(self.client, table)
|
81
|
+
args = create.prepare_client_args(data=data, condition=condition)
|
82
|
+
|
83
|
+
self._actions.append({'Put': {'TableName': self.client.connection.table_name, **args}})
|
84
|
+
|
85
|
+
def update(
|
86
|
+
self,
|
87
|
+
table: 'resources.Table',
|
88
|
+
data: Dict[str, Any],
|
89
|
+
condition: Dict[str, Any] | None = None,
|
90
|
+
shallow: bool = False,
|
91
|
+
) -> None:
|
92
|
+
"""
|
93
|
+
Update an item on a table as part of a transaction.
|
94
|
+
|
95
|
+
:arg table: Table instance.
|
96
|
+
:arg data: Data to update.
|
97
|
+
:param condition: Optional condition expression dict.
|
98
|
+
:param shallow: Activates shallow update mode (so that whole nested items are updated at once).
|
99
|
+
"""
|
100
|
+
self._validate()
|
101
|
+
|
102
|
+
update = api.UpdateItem(self.client, table)
|
103
|
+
args = update.prepare_client_args(data=data, condition=condition, shallow=shallow)
|
104
|
+
|
105
|
+
self._actions.append({'Update': {'TableName': self.client.connection.table_name, **args}})
|
106
|
+
|
107
|
+
def delete(
|
108
|
+
self,
|
109
|
+
table: 'resources.Table',
|
110
|
+
key: Dict[str, Any],
|
111
|
+
condition: Dict[str, Any] | None = None,
|
112
|
+
) -> None:
|
113
|
+
"""
|
114
|
+
Delete an item on a table as part of a transaction.
|
115
|
+
|
116
|
+
:arg table: Table instance.
|
117
|
+
:arg key: Record key as a dict.
|
118
|
+
:param condition: Optional condition expression dict.
|
119
|
+
"""
|
120
|
+
self._validate()
|
121
|
+
|
122
|
+
delete = api.Delete(self.client, table)
|
123
|
+
args = delete.prepare_client_args(key=key, condition=condition)
|
124
|
+
|
125
|
+
self._actions.append({'Delete': {'TableName': self.client.connection.table_name, **args}})
|
126
|
+
|
127
|
+
def condition_check(
|
128
|
+
self,
|
129
|
+
table: 'resources.Table',
|
130
|
+
key: Dict[str, Any],
|
131
|
+
condition: Dict[str, Any],
|
132
|
+
) -> None:
|
133
|
+
"""
|
134
|
+
Perform a condition check on an item as part of a transaction.
|
135
|
+
|
136
|
+
:arg table: Table instance.
|
137
|
+
:arg key: Record key as a dict.
|
138
|
+
:arg condition: Condition expression dict.
|
139
|
+
"""
|
140
|
+
self._validate()
|
141
|
+
|
142
|
+
condition_check = api.ConditionCheck(self.client, table)
|
143
|
+
args = condition_check.prepare_client_args(key=key, condition=condition)
|
144
|
+
|
145
|
+
self._actions.append({'ConditionCheck': {'TableName': self.client.connection.table_name, **args}})
|
@@ -0,0 +1,33 @@
|
|
1
|
+
__all__ = ['UpdateExpressionBuilder']
|
2
|
+
|
3
|
+
from typing import Dict, Any
|
4
|
+
|
5
|
+
from .utils import flatten_dict, UpdateKeys, UpdateValues
|
6
|
+
|
7
|
+
|
8
|
+
class UpdateExpressionBuilder:
|
9
|
+
def __init__(self, data: Dict[str, Any], shallow: bool = False) -> None:
|
10
|
+
"""
|
11
|
+
DynamoDB update expression builder.
|
12
|
+
|
13
|
+
:arg data: Serialised data to build expression from.
|
14
|
+
:param shallow: Activates shallow update mode (so that whole nested items are updated at once).
|
15
|
+
"""
|
16
|
+
if not shallow:
|
17
|
+
data = flatten_dict(data)
|
18
|
+
|
19
|
+
self.keys = UpdateKeys()
|
20
|
+
self.update_expression = UpdateValues()
|
21
|
+
|
22
|
+
for index, (key, value) in enumerate(data.items()):
|
23
|
+
key = self.keys.add(key)
|
24
|
+
self.update_expression.add(key, value)
|
25
|
+
|
26
|
+
@property
|
27
|
+
def args(self):
|
28
|
+
"""Returns the arguments for the boto3 API call."""
|
29
|
+
return {
|
30
|
+
'ExpressionAttributeNames': self.keys.expression_names,
|
31
|
+
'ExpressionAttributeValues': self.update_expression.values,
|
32
|
+
'UpdateExpression': 'SET ' + ', '.join(self.update_expression.expressions),
|
33
|
+
}
|
@@ -0,0 +1,139 @@
|
|
1
|
+
__all__ = ['UpdateItem']
|
2
|
+
|
3
|
+
from typing import Any, Dict, Optional, Literal
|
4
|
+
|
5
|
+
from boto3.dynamodb.types import TypeSerializer
|
6
|
+
from botocore.exceptions import ClientError
|
7
|
+
|
8
|
+
import nuql
|
9
|
+
from nuql import types, api, resources
|
10
|
+
|
11
|
+
|
12
|
+
class UpdateItem(api.Boto3Adapter):
|
13
|
+
serialisation_action: Literal['create', 'update'] = 'update'
|
14
|
+
|
15
|
+
def prepare_client_args(
|
16
|
+
self,
|
17
|
+
data: Dict[str, Any],
|
18
|
+
condition: Dict[str, Any] | None = None,
|
19
|
+
shallow: bool = False,
|
20
|
+
**kwargs,
|
21
|
+
) -> Dict[str, Any]:
|
22
|
+
"""
|
23
|
+
Prepares the request args for updating an item in the table (client API).
|
24
|
+
|
25
|
+
:arg data: Data to update.
|
26
|
+
:param condition: Optional condition expression.
|
27
|
+
:param shallow: Activates shallow update mode (so that whole nested items are updated at once).
|
28
|
+
:param kwargs: Additional args to pass to the request.
|
29
|
+
:return: New item dict.
|
30
|
+
"""
|
31
|
+
# Serialise the data for update
|
32
|
+
key = self.table.serialiser.serialise_key(data)
|
33
|
+
serialised_data = {k: v for k, v in self.table.serialiser.serialise('update', data).items() if k not in key}
|
34
|
+
|
35
|
+
# Marshall the key into DynamoDB format
|
36
|
+
serialiser = TypeSerializer()
|
37
|
+
marshalled_key = {k: serialiser.serialize(v) for k, v in key.items()}
|
38
|
+
|
39
|
+
# Generate the update condition
|
40
|
+
resources.validate_condition_dict(condition)
|
41
|
+
condition = api.Condition(
|
42
|
+
table=self.table,
|
43
|
+
condition=condition,
|
44
|
+
condition_type='ConditionExpression'
|
45
|
+
)
|
46
|
+
self.on_condition(condition)
|
47
|
+
|
48
|
+
# Generate the update expression
|
49
|
+
update = api.UpdateExpressionBuilder(serialised_data, shallow=shallow)
|
50
|
+
args = {
|
51
|
+
'Key': marshalled_key,
|
52
|
+
**resources.merge_dicts(update.args, condition.client_args),
|
53
|
+
**kwargs
|
54
|
+
}
|
55
|
+
|
56
|
+
# Serialise ExpressionAttributeValues into DynamoDB format
|
57
|
+
if 'ExpressionAttributeValues' in args:
|
58
|
+
for key, value in args['ExpressionAttributeValues'].items():
|
59
|
+
args['ExpressionAttributeValues'][key] = serialiser.serialize(value)
|
60
|
+
|
61
|
+
# Remove empty ExpressionAttributeValues
|
62
|
+
if 'ExpressionAttributeValues' in args and not args['ExpressionAttributeValues']:
|
63
|
+
args.pop('ExpressionAttributeValues')
|
64
|
+
|
65
|
+
return args
|
66
|
+
|
67
|
+
def prepare_args(
|
68
|
+
self,
|
69
|
+
data: Dict[str, Any],
|
70
|
+
condition: Dict[str, Any] | None = None,
|
71
|
+
shallow: bool = False,
|
72
|
+
**kwargs,
|
73
|
+
) -> Dict[str, Any]:
|
74
|
+
"""
|
75
|
+
Prepares the request args for updating an item in the table (resource API).
|
76
|
+
|
77
|
+
:arg data: Data to update.
|
78
|
+
:param condition: Optional condition expression.
|
79
|
+
:param shallow: Activates shallow update mode (so that whole nested items are updated at once).
|
80
|
+
:param kwargs: Additional args to pass to the request.
|
81
|
+
:return: New item dict.
|
82
|
+
"""
|
83
|
+
# Serialise the data for update
|
84
|
+
key = self.table.serialiser.serialise_key(data)
|
85
|
+
serialised_data = {k: v for k, v in self.table.serialiser.serialise('update', data).items() if k not in key}
|
86
|
+
|
87
|
+
# Generate the update condition
|
88
|
+
resources.validate_condition_dict(condition)
|
89
|
+
condition = api.Condition(
|
90
|
+
table=self.table,
|
91
|
+
condition=condition,
|
92
|
+
condition_type='ConditionExpression'
|
93
|
+
)
|
94
|
+
self.on_condition(condition)
|
95
|
+
|
96
|
+
# Generate the update expression
|
97
|
+
update = api.UpdateExpressionBuilder(serialised_data, shallow=shallow)
|
98
|
+
return {'Key': key, **update.args, **condition.resource_args, **kwargs}
|
99
|
+
|
100
|
+
def on_condition(self, condition: 'api.Condition') -> None:
|
101
|
+
"""
|
102
|
+
Make changes to the condition expression before request.
|
103
|
+
|
104
|
+
:arg condition: Condition instance.
|
105
|
+
"""
|
106
|
+
index = self.table.indexes.primary
|
107
|
+
keys = [index['hash']]
|
108
|
+
|
109
|
+
# Append sort key if defined in primary index, but only if it exists in the schema
|
110
|
+
if 'sort' in index and index['sort'] and index['sort'] in self.table.fields:
|
111
|
+
keys.append(index['sort'])
|
112
|
+
|
113
|
+
expression = ' and '.join([f'attribute_exists({key})' for key in keys])
|
114
|
+
|
115
|
+
# Add the expression to the existing condition
|
116
|
+
condition.append(expression)
|
117
|
+
|
118
|
+
def invoke_sync(
|
119
|
+
self,
|
120
|
+
data: Dict[str, Any],
|
121
|
+
condition: Dict[str, Any] | None = None,
|
122
|
+
shallow: bool = False
|
123
|
+
) -> Dict[str, Any]:
|
124
|
+
"""
|
125
|
+
Updates an item in the table.
|
126
|
+
|
127
|
+
:arg data: Data to update.
|
128
|
+
:param condition: Optional condition expression.
|
129
|
+
:param shallow: Activates shallow update mode (so that whole nested items are updated at once).
|
130
|
+
:return: New item dict.
|
131
|
+
"""
|
132
|
+
args = self.prepare_args(data=data, condition=condition, shallow=shallow, ReturnValues='ALL_NEW')
|
133
|
+
|
134
|
+
try:
|
135
|
+
response = self.connection.table.update_item(**args)
|
136
|
+
except ClientError as exc:
|
137
|
+
raise nuql.Boto3Error(exc, args)
|
138
|
+
|
139
|
+
return self.table.serialiser.deserialise(response['Attributes'])
|
nuql/api/update/utils.py
ADDED
@@ -0,0 +1,126 @@
|
|
1
|
+
__all__ = ['flatten_dict', 'UpdateKeys', 'UpdateValues', 'Incrementor', 'decrement', 'increment']
|
2
|
+
|
3
|
+
from decimal import Decimal
|
4
|
+
from typing import Dict, Any
|
5
|
+
|
6
|
+
|
7
|
+
def flatten_dict(data: Dict[str, Any], parent: str = None) -> Dict[str, Any]:
|
8
|
+
"""
|
9
|
+
Flattens a nested dict with dot notation keys.
|
10
|
+
|
11
|
+
:arg data: Dict to flatten.
|
12
|
+
:param parent: Parent key name if recursive.
|
13
|
+
:return: Flattened dict.
|
14
|
+
"""
|
15
|
+
# Default initialiser
|
16
|
+
if parent is None:
|
17
|
+
parent = ''
|
18
|
+
|
19
|
+
items = []
|
20
|
+
|
21
|
+
for key, value in data.items():
|
22
|
+
new_key = (parent + '.' + key) if parent else key
|
23
|
+
|
24
|
+
# Process nested dict
|
25
|
+
if isinstance(value, dict):
|
26
|
+
if not value:
|
27
|
+
items.append((new_key, value))
|
28
|
+
else:
|
29
|
+
items.extend(flatten_dict(value, new_key).items())
|
30
|
+
|
31
|
+
else:
|
32
|
+
items.append((new_key, value))
|
33
|
+
|
34
|
+
return dict(items)
|
35
|
+
|
36
|
+
|
37
|
+
class UpdateKeys:
|
38
|
+
def __init__(self) -> None:
|
39
|
+
"""State manager for keys in an update expression."""
|
40
|
+
self.current_index = 0
|
41
|
+
self.key_dict = {}
|
42
|
+
|
43
|
+
@property
|
44
|
+
def expression_names(self):
|
45
|
+
return {value: key for key, value in self.key_dict.items()}
|
46
|
+
|
47
|
+
def add(self, key: str) -> str:
|
48
|
+
"""
|
49
|
+
Parse a key for the update expression.
|
50
|
+
|
51
|
+
:arg key: Key to parse.
|
52
|
+
:return: Resulting keys for the update expression.
|
53
|
+
"""
|
54
|
+
keys = []
|
55
|
+
|
56
|
+
# Parse nested parts and see if the key exists in the dictionary
|
57
|
+
for part in key.split('.'):
|
58
|
+
if part in self.key_dict:
|
59
|
+
keys.append(self.key_dict[part])
|
60
|
+
else:
|
61
|
+
keys.append(f'#key_{self.current_index}')
|
62
|
+
self.key_dict[part] = f'#key_{self.current_index}'
|
63
|
+
self.current_index += 1
|
64
|
+
|
65
|
+
return '.'.join(keys)
|
66
|
+
|
67
|
+
|
68
|
+
class UpdateValues:
|
69
|
+
def __init__(self) -> None:
|
70
|
+
"""State manager for values in an update expression."""
|
71
|
+
self.current_index = 0
|
72
|
+
self.values = {}
|
73
|
+
self.expressions = []
|
74
|
+
|
75
|
+
def add(self, key: str, value: Any) -> None:
|
76
|
+
"""
|
77
|
+
Add a value to the update expression.
|
78
|
+
|
79
|
+
:arg key: Key in the update expression.
|
80
|
+
:arg value: Any value.
|
81
|
+
:return: Resulting value key for the update expression.
|
82
|
+
"""
|
83
|
+
value_key = f':val_{self.current_index}'
|
84
|
+
|
85
|
+
if isinstance(value, Incrementor):
|
86
|
+
self.values[value_key] = value.value
|
87
|
+
self.expressions.append(f'{key} = {key} {"-" if value.negative else "+"} {value_key}')
|
88
|
+
else:
|
89
|
+
self.values[value_key] = value
|
90
|
+
self.expressions.append(f'{key} = {value_key}')
|
91
|
+
|
92
|
+
self.current_index += 1
|
93
|
+
|
94
|
+
|
95
|
+
class Incrementor:
|
96
|
+
def __init__(self, value: int | float | Decimal, negative: bool = False) -> None:
|
97
|
+
"""
|
98
|
+
Data class for increment/decrement operations.
|
99
|
+
:arg value: Value to increment/decrement.
|
100
|
+
:param negative: Set to true to decrement the value.
|
101
|
+
"""
|
102
|
+
if not isinstance(value, Decimal):
|
103
|
+
value = Decimal(str(value))
|
104
|
+
|
105
|
+
self.value = value
|
106
|
+
self.negative = negative
|
107
|
+
|
108
|
+
|
109
|
+
def increment(value: int | float | Decimal) -> Incrementor:
|
110
|
+
"""
|
111
|
+
Increment a value on an update expression.
|
112
|
+
|
113
|
+
:arg value: Value to increment.
|
114
|
+
:return: Incrementor instance.
|
115
|
+
"""
|
116
|
+
return Incrementor(value, negative=False)
|
117
|
+
|
118
|
+
|
119
|
+
def decrement(value: int | float | Decimal) -> Incrementor:
|
120
|
+
"""
|
121
|
+
Decrement a value on an update expression.
|
122
|
+
|
123
|
+
:arg value: Value to decrement.
|
124
|
+
:return: Incrementor instance.
|
125
|
+
"""
|
126
|
+
return Incrementor(value, negative=True)
|
nuql/api/upsert.py
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
__all__ = ['Upsert']
|
2
|
+
|
3
|
+
from typing import Any, Dict
|
4
|
+
|
5
|
+
import nuql
|
6
|
+
from nuql import api
|
7
|
+
|
8
|
+
|
9
|
+
class Upsert(api.Boto3Adapter):
|
10
|
+
def invoke_sync(self, data: Dict[str, Any], shallow: bool = False) -> Dict[str, Any]:
|
11
|
+
"""
|
12
|
+
Updates an item in the table if it exists, otherwise creates a new one.
|
13
|
+
|
14
|
+
[NOTE]
|
15
|
+
Conditions aren't allowed for this API to avoid ambiguous
|
16
|
+
ConditionCheckFailedException (as this is a catch-all for any condition).
|
17
|
+
|
18
|
+
:arg data: Data to upsert.
|
19
|
+
:param shallow: Activates shallow update mode (so that whole nested items are updated at once).
|
20
|
+
:return: New item dict.
|
21
|
+
"""
|
22
|
+
try:
|
23
|
+
update = api.UpdateItem(self.client, self.table)
|
24
|
+
return update.invoke_sync(data=data, shallow=shallow)
|
25
|
+
|
26
|
+
except nuql.Boto3Error as exc:
|
27
|
+
# When condition failed the create API will be used instead.
|
28
|
+
if exc.code == 'ConditionalCheckFailedException':
|
29
|
+
create = api.Create(self.client, self.table)
|
30
|
+
return create.invoke_sync(data=data)
|
31
|
+
|
32
|
+
raise exc
|