clear-skies-aws 1.10.2__py3-none-any.whl → 2.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.
- {clear_skies_aws-1.10.2.dist-info → clear_skies_aws-2.0.1.dist-info}/METADATA +36 -35
- clear_skies_aws-2.0.1.dist-info/RECORD +4 -0
- {clear_skies_aws-1.10.2.dist-info → clear_skies_aws-2.0.1.dist-info}/WHEEL +1 -1
- clear_skies_aws-2.0.1.dist-info/licenses/LICENSE +21 -0
- clear_skies_aws-1.10.2.dist-info/LICENSE +0 -7
- clear_skies_aws-1.10.2.dist-info/RECORD +0 -71
- clearskies_aws/__init__.py +0 -2
- clearskies_aws/actions/__init__.py +0 -108
- clearskies_aws/actions/action_aws.py +0 -118
- clearskies_aws/actions/assume_role.py +0 -102
- clearskies_aws/actions/assume_role_test.py +0 -72
- clearskies_aws/actions/ses.py +0 -194
- clearskies_aws/actions/ses_test.py +0 -89
- clearskies_aws/actions/sns.py +0 -64
- clearskies_aws/actions/sns_test.py +0 -77
- clearskies_aws/actions/sqs.py +0 -82
- clearskies_aws/actions/sqs_test.py +0 -127
- clearskies_aws/actions/step_function.py +0 -66
- clearskies_aws/actions/step_function_test.py +0 -103
- clearskies_aws/backends/__init__.py +0 -12
- clearskies_aws/backends/dynamo_db_backend.py +0 -614
- clearskies_aws/backends/dynamo_db_backend_test.py +0 -300
- clearskies_aws/backends/dynamo_db_condition_parser.py +0 -365
- clearskies_aws/backends/dynamo_db_condition_parser_test.py +0 -266
- clearskies_aws/backends/dynamo_db_parti_ql_backend.py +0 -1123
- clearskies_aws/backends/dynamo_db_parti_ql_backend_test.py +0 -544
- clearskies_aws/backends/sqs_backend.py +0 -80
- clearskies_aws/backends/sqs_backend_test.py +0 -31
- clearskies_aws/contexts/__init__.py +0 -10
- clearskies_aws/contexts/cli.py +0 -19
- clearskies_aws/contexts/cli_websocket_mock.py +0 -33
- clearskies_aws/contexts/lambda_api_gateway.py +0 -30
- clearskies_aws/contexts/lambda_api_gateway_web_socket.py +0 -30
- clearskies_aws/contexts/lambda_elb.py +0 -30
- clearskies_aws/contexts/lambda_http_gateway.py +0 -30
- clearskies_aws/contexts/lambda_invocation.py +0 -48
- clearskies_aws/contexts/lambda_sns.py +0 -43
- clearskies_aws/contexts/lambda_sqs_standard_partial_batch.py +0 -51
- clearskies_aws/contexts/lambda_sqs_standard_partial_batch_test.py +0 -66
- clearskies_aws/contexts/wsgi.py +0 -19
- clearskies_aws/di/__init__.py +0 -1
- clearskies_aws/di/standard_dependencies.py +0 -60
- clearskies_aws/handlers/__init__.py +0 -2
- clearskies_aws/handlers/secrets_manager_rotation.py +0 -174
- clearskies_aws/handlers/simple_body_routing.py +0 -39
- clearskies_aws/input_outputs/__init__.py +0 -8
- clearskies_aws/input_outputs/cli_websocket_mock.py +0 -12
- clearskies_aws/input_outputs/lambda_api_gateway.py +0 -105
- clearskies_aws/input_outputs/lambda_api_gateway_test.py +0 -87
- clearskies_aws/input_outputs/lambda_api_gateway_web_socket.py +0 -8
- clearskies_aws/input_outputs/lambda_elb.py +0 -21
- clearskies_aws/input_outputs/lambda_http_gateway.py +0 -12
- clearskies_aws/input_outputs/lambda_invocation.py +0 -34
- clearskies_aws/input_outputs/lambda_sns.py +0 -52
- clearskies_aws/input_outputs/lambda_sqs_standard.py +0 -54
- clearskies_aws/mocks/__init__.py +0 -1
- clearskies_aws/mocks/actions/__init__.py +0 -6
- clearskies_aws/mocks/actions/ses.py +0 -28
- clearskies_aws/mocks/actions/sns.py +0 -23
- clearskies_aws/mocks/actions/sqs.py +0 -23
- clearskies_aws/mocks/actions/step_function.py +0 -26
- clearskies_aws/secrets/__init__.py +0 -7
- clearskies_aws/secrets/additional_configs/__init__.py +0 -54
- clearskies_aws/secrets/additional_configs/iam_db_auth.py +0 -29
- clearskies_aws/secrets/additional_configs/iam_db_auth_with_ssm.py +0 -92
- clearskies_aws/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssh_cert_bastion.py +0 -81
- clearskies_aws/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssm_bastion.py +0 -141
- clearskies_aws/secrets/akeyless_with_ssm_cache.py +0 -46
- clearskies_aws/secrets/parameter_store.py +0 -50
- clearskies_aws/secrets/parameter_store_test.py +0 -18
- clearskies_aws/secrets/secrets_manager.py +0 -75
- clearskies_aws/secrets/secrets_manager_test.py +0 -18
- clearskies_aws/web_socket_connection_model.py +0 -43
|
@@ -1,544 +0,0 @@
|
|
|
1
|
-
import base64
|
|
2
|
-
import json
|
|
3
|
-
import re
|
|
4
|
-
import unittest
|
|
5
|
-
from decimal import Decimal
|
|
6
|
-
from unittest.mock import MagicMock, call, patch
|
|
7
|
-
|
|
8
|
-
from boto3.session import Session as Boto3Session
|
|
9
|
-
from botocore.exceptions import ClientError
|
|
10
|
-
from clearskies import Model
|
|
11
|
-
from clearskies.autodoc.schema import String as AutoDocString
|
|
12
|
-
|
|
13
|
-
from clearskies_aws.backends.dynamo_db_parti_ql_backend import (
|
|
14
|
-
DynamoDBPartiQLBackend,
|
|
15
|
-
DynamoDBPartiQLCursor,
|
|
16
|
-
)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
@patch("clearskies_aws.backends.dynamo_db_parti_ql_backend.logger")
|
|
20
|
-
class TestDynamoDBPartiQLBackend(unittest.TestCase):
|
|
21
|
-
|
|
22
|
-
def setUp(self):
|
|
23
|
-
"""Set up the test environment before each test method."""
|
|
24
|
-
self.mock_boto3_session = MagicMock(spec=Boto3Session)
|
|
25
|
-
self.mock_dynamodb_client = MagicMock()
|
|
26
|
-
self.mock_boto3_session.client.return_value = self.mock_dynamodb_client
|
|
27
|
-
|
|
28
|
-
self.cursor_under_test = DynamoDBPartiQLCursor(self.mock_boto3_session)
|
|
29
|
-
|
|
30
|
-
self.backend = DynamoDBPartiQLBackend(self.cursor_under_test)
|
|
31
|
-
self.mock_model = MagicMock(spec=Model)
|
|
32
|
-
self.mock_model.get_table_name = MagicMock(return_value="my_test_table")
|
|
33
|
-
self.mock_model.id_column_name = "id"
|
|
34
|
-
|
|
35
|
-
self.mock_model.schema = MagicMock()
|
|
36
|
-
self.mock_model.schema.return_value.indexes = {}
|
|
37
|
-
|
|
38
|
-
self.backend._get_table_description = MagicMock()
|
|
39
|
-
self.backend._get_table_description.return_value = {
|
|
40
|
-
"KeySchema": [{"AttributeName": "id", "KeyType": "HASH"}],
|
|
41
|
-
"GlobalSecondaryIndexes": [],
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
def _get_base_config(self, table_name="test_table", **overrides):
|
|
45
|
-
"""Helper to create a base configuration dictionary with defaults."""
|
|
46
|
-
config = {
|
|
47
|
-
"table_name": table_name,
|
|
48
|
-
"wheres": [],
|
|
49
|
-
"sorts": [],
|
|
50
|
-
"limit": None,
|
|
51
|
-
"pagination": {},
|
|
52
|
-
"model_columns": [],
|
|
53
|
-
"select_all": False,
|
|
54
|
-
"selects": [],
|
|
55
|
-
"group_by_column": None,
|
|
56
|
-
"joins": [],
|
|
57
|
-
}
|
|
58
|
-
config.update(overrides)
|
|
59
|
-
return config
|
|
60
|
-
|
|
61
|
-
def test_as_sql_simple_select_all(self, mock_logger_arg):
|
|
62
|
-
"""Test SQL generation for a simple 'SELECT *' statement."""
|
|
63
|
-
config = self._get_base_config(table_name="users", select_all=True)
|
|
64
|
-
config["_chosen_index_name"] = None
|
|
65
|
-
statement, params, limit, next_token = self.backend.as_sql(config)
|
|
66
|
-
self.assertEqual('SELECT * FROM "users"', statement)
|
|
67
|
-
self.assertEqual([], params)
|
|
68
|
-
self.assertIsNone(limit)
|
|
69
|
-
self.assertEqual(next_token, config.get("pagination", {}).get("next_token"))
|
|
70
|
-
|
|
71
|
-
def test_as_sql_select_specific_columns(self, mock_logger_arg):
|
|
72
|
-
"""Test SQL generation for selecting specific columns."""
|
|
73
|
-
config = self._get_base_config(table_name="products", selects=["name", "price"])
|
|
74
|
-
config["_chosen_index_name"] = None
|
|
75
|
-
statement, params, limit, next_token = self.backend.as_sql(config)
|
|
76
|
-
self.assertEqual('SELECT "name", "price" FROM "products"', statement)
|
|
77
|
-
self.assertEqual([], params)
|
|
78
|
-
|
|
79
|
-
def test_as_sql_select_all_and_specific_columns_uses_specific(
|
|
80
|
-
self, mock_logger_arg
|
|
81
|
-
):
|
|
82
|
-
"""Test SQL generation uses specific columns if both select_all and selects are given."""
|
|
83
|
-
config = self._get_base_config(
|
|
84
|
-
table_name="inventory", select_all=True, selects=["item_id", "stock_count"]
|
|
85
|
-
)
|
|
86
|
-
config["_chosen_index_name"] = None
|
|
87
|
-
statement, params, limit, next_token = self.backend.as_sql(config)
|
|
88
|
-
expected_sql = 'SELECT "item_id", "stock_count" FROM "inventory"'
|
|
89
|
-
self.assertEqual(expected_sql, statement)
|
|
90
|
-
mock_logger_arg.warning.assert_any_call(
|
|
91
|
-
"Both 'select_all=True' and specific 'selects' were provided. Using specific 'selects'."
|
|
92
|
-
)
|
|
93
|
-
|
|
94
|
-
def test_as_sql_default_select_if_no_select_all_or_selects(self, mock_logger_arg):
|
|
95
|
-
"""Test SQL generation defaults to 'SELECT *' if no specific columns are given."""
|
|
96
|
-
config = self._get_base_config(table_name="orders")
|
|
97
|
-
config["_chosen_index_name"] = None
|
|
98
|
-
statement, params, limit, next_token = self.backend.as_sql(config)
|
|
99
|
-
self.assertEqual('SELECT * FROM "orders"', statement)
|
|
100
|
-
self.assertEqual([], params)
|
|
101
|
-
|
|
102
|
-
def test_as_sql_with_wheres(self, mock_logger_arg):
|
|
103
|
-
"""Test SQL generation with WHERE clauses."""
|
|
104
|
-
config = self._get_base_config(
|
|
105
|
-
table_name="customers",
|
|
106
|
-
select_all=True,
|
|
107
|
-
wheres=[
|
|
108
|
-
{"column": "city", "operator": "=", "values": ["New York"]},
|
|
109
|
-
{"column": "age", "operator": ">", "values": [30]},
|
|
110
|
-
],
|
|
111
|
-
)
|
|
112
|
-
config["_chosen_index_name"] = None
|
|
113
|
-
statement, params, limit, next_token = self.backend.as_sql(config)
|
|
114
|
-
expected_statement = 'SELECT * FROM "customers" WHERE "city" = ? AND "age" > ?'
|
|
115
|
-
expected_parameters = [{"S": "New York"}, {"N": "30"}]
|
|
116
|
-
self.assertEqual(expected_statement, statement)
|
|
117
|
-
self.assertEqual(expected_parameters, params)
|
|
118
|
-
|
|
119
|
-
def test_as_sql_with_sorts(self, mock_logger_arg):
|
|
120
|
-
"""Test SQL generation with ORDER BY clauses (no table prefix for columns)."""
|
|
121
|
-
config = self._get_base_config(
|
|
122
|
-
table_name="items",
|
|
123
|
-
select_all=True,
|
|
124
|
-
sorts=[
|
|
125
|
-
{"column": "name", "direction": "ASC"},
|
|
126
|
-
{"column": "created_at", "direction": "DESC"},
|
|
127
|
-
],
|
|
128
|
-
)
|
|
129
|
-
config["_chosen_index_name"] = None
|
|
130
|
-
statement, params, limit, next_token = self.backend.as_sql(config)
|
|
131
|
-
expected_statement = (
|
|
132
|
-
'SELECT * FROM "items" ORDER BY "name" ASC, "created_at" DESC'
|
|
133
|
-
)
|
|
134
|
-
self.assertEqual(expected_statement, statement)
|
|
135
|
-
|
|
136
|
-
def test_as_sql_with_index_name(self, mock_logger_arg):
|
|
137
|
-
"""Test SQL generation uses index name in FROM clause if provided."""
|
|
138
|
-
config = self._get_base_config(table_name="my_table", select_all=True)
|
|
139
|
-
config["_chosen_index_name"] = "my_gsi"
|
|
140
|
-
|
|
141
|
-
statement, params, limit, next_token = self.backend.as_sql(config)
|
|
142
|
-
self.assertEqual('SELECT * FROM "my_table"."my_gsi"', statement)
|
|
143
|
-
|
|
144
|
-
def test_as_sql_ignores_group_by_and_joins(self, mock_logger_arg):
|
|
145
|
-
"""Test that GROUP BY and JOIN configurations are ignored for SQL but logged."""
|
|
146
|
-
config = self._get_base_config(
|
|
147
|
-
table_name="log_data", group_by_column="level", joins=["some_join_info"]
|
|
148
|
-
)
|
|
149
|
-
config["_chosen_index_name"] = None
|
|
150
|
-
|
|
151
|
-
statement, _, _, _ = self.backend.as_sql(config)
|
|
152
|
-
self.assertNotIn("GROUP BY", statement.upper())
|
|
153
|
-
self.assertNotIn("JOIN", statement.upper())
|
|
154
|
-
mock_logger_arg.warning.assert_any_call(
|
|
155
|
-
"Configuration included 'group_by_column=level', "
|
|
156
|
-
"but GROUP BY is not supported by this DynamoDB PartiQL backend and will be ignored for SQL generation."
|
|
157
|
-
)
|
|
158
|
-
mock_logger_arg.warning.assert_any_call(
|
|
159
|
-
"Configuration included 'joins=['some_join_info']', "
|
|
160
|
-
"but JOINs are not supported by this DynamoDB PartiQL backend and will be ignored for SQL generation."
|
|
161
|
-
)
|
|
162
|
-
|
|
163
|
-
def test_check_query_configuration_sort_with_base_table_hash_key_equality(
|
|
164
|
-
self, mock_logger_arg
|
|
165
|
-
):
|
|
166
|
-
"""Test _check_query_configuration allows sort if base table hash key equality exists."""
|
|
167
|
-
self.backend._get_table_description.return_value = {
|
|
168
|
-
"KeySchema": [{"AttributeName": "id", "KeyType": "HASH"}],
|
|
169
|
-
"GlobalSecondaryIndexes": [],
|
|
170
|
-
}
|
|
171
|
-
config = self._get_base_config(
|
|
172
|
-
table_name="my_test_table",
|
|
173
|
-
sorts=[{"column": "name", "direction": "ASC"}],
|
|
174
|
-
wheres=[{"column": "id", "operator": "=", "values": ["some_id"]}],
|
|
175
|
-
)
|
|
176
|
-
processed_config = self.backend._check_query_configuration(
|
|
177
|
-
config, self.mock_model
|
|
178
|
-
)
|
|
179
|
-
self.assertIsNone(processed_config.get("_chosen_index_name"))
|
|
180
|
-
self.assertEqual(processed_config.get("_partition_key_for_target"), "id")
|
|
181
|
-
|
|
182
|
-
def test_check_query_configuration_sort_raises_error_if_no_hash_key_equality(
|
|
183
|
-
self, mock_logger_arg
|
|
184
|
-
):
|
|
185
|
-
"""Test _check_query_configuration raises ValueError for sort without hash key equality."""
|
|
186
|
-
self.backend._get_table_description.return_value = {
|
|
187
|
-
"KeySchema": [{"AttributeName": "id", "KeyType": "HASH"}],
|
|
188
|
-
"GlobalSecondaryIndexes": [],
|
|
189
|
-
}
|
|
190
|
-
config = self._get_base_config(
|
|
191
|
-
table_name="my_test_table",
|
|
192
|
-
sorts=[{"column": "name", "direction": "ASC"}],
|
|
193
|
-
wheres=[{"column": "status", "operator": "=", "values": ["active"]}],
|
|
194
|
-
)
|
|
195
|
-
expected_error_message = "DynamoDB PartiQL queries with ORDER BY on 'my_test_table' require an equality condition on its partition key ('id') in the WHERE clause."
|
|
196
|
-
with self.assertRaisesRegex(ValueError, re.escape(expected_error_message)):
|
|
197
|
-
self.backend._check_query_configuration(config, self.mock_model)
|
|
198
|
-
|
|
199
|
-
def test_check_query_configuration_sort_uses_gsi_if_partition_key_matches(
|
|
200
|
-
self, mock_logger_arg
|
|
201
|
-
):
|
|
202
|
-
"""Test _check_query_configuration selects GSI if its partition key matches WHERE and can sort."""
|
|
203
|
-
self.backend._get_table_description.return_value = {
|
|
204
|
-
"KeySchema": [{"AttributeName": "id", "KeyType": "HASH"}],
|
|
205
|
-
"GlobalSecondaryIndexes": [
|
|
206
|
-
{
|
|
207
|
-
"IndexName": "domain-status-index",
|
|
208
|
-
"KeySchema": [
|
|
209
|
-
{"AttributeName": "domain", "KeyType": "HASH"},
|
|
210
|
-
{"AttributeName": "status", "KeyType": "RANGE"},
|
|
211
|
-
],
|
|
212
|
-
"Projection": {"ProjectionType": "ALL"},
|
|
213
|
-
}
|
|
214
|
-
],
|
|
215
|
-
}
|
|
216
|
-
config = self._get_base_config(
|
|
217
|
-
table_name="my_test_table",
|
|
218
|
-
sorts=[{"column": "status", "direction": "DESC"}],
|
|
219
|
-
wheres=[{"column": "domain", "operator": "=", "values": ["example.com"]}],
|
|
220
|
-
)
|
|
221
|
-
processed_config = self.backend._check_query_configuration(
|
|
222
|
-
config, self.mock_model
|
|
223
|
-
)
|
|
224
|
-
self.assertEqual(
|
|
225
|
-
processed_config.get("_chosen_index_name"), "domain-status-index"
|
|
226
|
-
)
|
|
227
|
-
self.assertEqual(processed_config.get("_partition_key_for_target"), "domain")
|
|
228
|
-
|
|
229
|
-
def test_count_uses_native_query_with_pk_condition(self, mock_logger_arg):
|
|
230
|
-
"""Test count() uses native DDB query when PK equality is present."""
|
|
231
|
-
self.backend._get_table_description.return_value = {
|
|
232
|
-
"KeySchema": [{"AttributeName": "id", "KeyType": "HASH"}]
|
|
233
|
-
}
|
|
234
|
-
config = self._get_base_config(
|
|
235
|
-
table_name="users",
|
|
236
|
-
wheres=[{"column": "id", "operator": "=", "values": ["user123"]}],
|
|
237
|
-
)
|
|
238
|
-
self.mock_dynamodb_client.query.return_value = {"Count": 10, "Items": []}
|
|
239
|
-
|
|
240
|
-
count = self.backend.count(config, self.mock_model)
|
|
241
|
-
self.assertEqual(count, 10)
|
|
242
|
-
self.mock_dynamodb_client.query.assert_called_once()
|
|
243
|
-
self.mock_dynamodb_client.scan.assert_not_called()
|
|
244
|
-
called_args = self.mock_dynamodb_client.query.call_args[1]
|
|
245
|
-
self.assertEqual(called_args.get("TableName"), "users")
|
|
246
|
-
self.assertEqual(called_args.get("Select"), "COUNT")
|
|
247
|
-
self.assertIn("KeyConditionExpression", called_args)
|
|
248
|
-
|
|
249
|
-
def test_count_uses_native_scan_without_pk_condition(self, mock_logger_arg):
|
|
250
|
-
"""Test count() uses native DDB scan when PK equality is NOT present."""
|
|
251
|
-
self.backend._get_table_description.return_value = {
|
|
252
|
-
"KeySchema": [{"AttributeName": "id", "KeyType": "HASH"}]
|
|
253
|
-
}
|
|
254
|
-
config = self._get_base_config(
|
|
255
|
-
table_name="users",
|
|
256
|
-
wheres=[{"column": "status", "operator": "=", "values": ["active"]}],
|
|
257
|
-
)
|
|
258
|
-
self.mock_dynamodb_client.scan.return_value = {"Count": 5, "Items": []}
|
|
259
|
-
|
|
260
|
-
count = self.backend.count(config, self.mock_model)
|
|
261
|
-
self.assertEqual(count, 5)
|
|
262
|
-
self.mock_dynamodb_client.scan.assert_called_once()
|
|
263
|
-
self.mock_dynamodb_client.query.assert_not_called()
|
|
264
|
-
called_args = self.mock_dynamodb_client.scan.call_args[1]
|
|
265
|
-
self.assertEqual(called_args.get("TableName"), "users")
|
|
266
|
-
self.assertEqual(called_args.get("Select"), "COUNT")
|
|
267
|
-
self.assertIn("FilterExpression", called_args)
|
|
268
|
-
|
|
269
|
-
def test_count_paginates_native_results(self, mock_logger_arg):
|
|
270
|
-
"""Test count() paginates and sums results from native DDB operations."""
|
|
271
|
-
self.backend._get_table_description.return_value = {
|
|
272
|
-
"KeySchema": [{"AttributeName": "id", "KeyType": "HASH"}]
|
|
273
|
-
}
|
|
274
|
-
config = self._get_base_config(table_name="large_table")
|
|
275
|
-
|
|
276
|
-
self.mock_dynamodb_client.scan.side_effect = [
|
|
277
|
-
{"Count": 100, "LastEvaluatedKey": {"id": {"S": "page1_end"}}},
|
|
278
|
-
{"Count": 50, "LastEvaluatedKey": {"id": {"S": "page2_end"}}},
|
|
279
|
-
{"Count": 25},
|
|
280
|
-
]
|
|
281
|
-
count = self.backend.count(config, self.mock_model)
|
|
282
|
-
self.assertEqual(count, 175)
|
|
283
|
-
self.assertEqual(self.mock_dynamodb_client.scan.call_count, 3)
|
|
284
|
-
|
|
285
|
-
def test_records_simple_fetch(self, mock_logger_arg):
|
|
286
|
-
"""Test records() fetching a single page of results without limit or pagination."""
|
|
287
|
-
config = self._get_base_config(table_name="users", select_all=True)
|
|
288
|
-
expected_statement = 'SELECT * FROM "users"'
|
|
289
|
-
ddb_items = [
|
|
290
|
-
{"id": {"S": "user1"}, "name": {"S": "Alice"}, "age": {"N": "30"}},
|
|
291
|
-
{"id": {"S": "user2"}, "name": {"S": "Bob"}, "age": {"N": "24"}},
|
|
292
|
-
]
|
|
293
|
-
self.mock_dynamodb_client.execute_statement.return_value = {"Items": ddb_items}
|
|
294
|
-
|
|
295
|
-
results = list(self.backend.records(config, self.mock_model))
|
|
296
|
-
|
|
297
|
-
expected_call_kwargs = {
|
|
298
|
-
"Statement": expected_statement,
|
|
299
|
-
}
|
|
300
|
-
self.mock_dynamodb_client.execute_statement.assert_called_once_with(
|
|
301
|
-
**expected_call_kwargs
|
|
302
|
-
)
|
|
303
|
-
self.assertEqual(len(results), 2)
|
|
304
|
-
# Assert based on what _map_from_boto3 currently does
|
|
305
|
-
self.assertEqual(
|
|
306
|
-
results[0], {"id": "user1", "name": "Alice", "age": Decimal("30")}
|
|
307
|
-
)
|
|
308
|
-
self.assertEqual(
|
|
309
|
-
results[1], {"id": "user2", "name": "Bob", "age": Decimal("24")}
|
|
310
|
-
)
|
|
311
|
-
self.assertIsNone(config["pagination"].get("next_page_token_for_response"))
|
|
312
|
-
|
|
313
|
-
def test_records_with_limit(self, mock_logger_arg):
|
|
314
|
-
"""Test records() respects the server-side limit passed to DynamoDB."""
|
|
315
|
-
config = self._get_base_config(table_name="products", limit=1, select_all=True)
|
|
316
|
-
expected_statement = 'SELECT * FROM "products"'
|
|
317
|
-
ddb_items = [{"id": {"S": "prod1"}, "price": {"N": "10.99"}}]
|
|
318
|
-
ddb_next_token = "fakeDDBNextToken"
|
|
319
|
-
|
|
320
|
-
self.mock_dynamodb_client.execute_statement.return_value = {
|
|
321
|
-
"Items": ddb_items,
|
|
322
|
-
"NextToken": ddb_next_token,
|
|
323
|
-
}
|
|
324
|
-
next_page_data = {}
|
|
325
|
-
results = list(self.backend.records(config, self.mock_model, next_page_data))
|
|
326
|
-
|
|
327
|
-
expected_call_kwargs = {
|
|
328
|
-
"Statement": expected_statement,
|
|
329
|
-
"Limit": 1,
|
|
330
|
-
}
|
|
331
|
-
self.mock_dynamodb_client.execute_statement.assert_called_once_with(
|
|
332
|
-
**expected_call_kwargs
|
|
333
|
-
)
|
|
334
|
-
self.assertEqual(len(results), 1)
|
|
335
|
-
self.assertEqual(results[0], {"id": "prod1", "price": Decimal("10.99")})
|
|
336
|
-
expected_client_token = self.backend.serialize_next_token_for_response(
|
|
337
|
-
ddb_next_token
|
|
338
|
-
)
|
|
339
|
-
self.assertEqual(
|
|
340
|
-
next_page_data["next_token"],
|
|
341
|
-
expected_client_token,
|
|
342
|
-
)
|
|
343
|
-
|
|
344
|
-
def test_records_pagination_flow(self, mock_logger_arg):
|
|
345
|
-
"""Test records() handling of client-provided token and returning DDB's next token."""
|
|
346
|
-
initial_ddb_token = "start_token_from_ddb_previously"
|
|
347
|
-
client_sends_this_token = self.backend.serialize_next_token_for_response(
|
|
348
|
-
initial_ddb_token
|
|
349
|
-
)
|
|
350
|
-
config1 = self._get_base_config(
|
|
351
|
-
table_name="events",
|
|
352
|
-
select_all=True,
|
|
353
|
-
pagination={"next_token": client_sends_this_token},
|
|
354
|
-
)
|
|
355
|
-
expected_statement = 'SELECT * FROM "events"'
|
|
356
|
-
ddb_items_page1 = [{"event_id": {"S": "evt1"}}]
|
|
357
|
-
ddb_next_token_page1 = "ddb_token_for_page2"
|
|
358
|
-
|
|
359
|
-
self.mock_dynamodb_client.execute_statement.return_value = {
|
|
360
|
-
"Items": ddb_items_page1,
|
|
361
|
-
"NextToken": ddb_next_token_page1,
|
|
362
|
-
}
|
|
363
|
-
next_page_data = {}
|
|
364
|
-
results_page1 = list(
|
|
365
|
-
self.backend.records(config1, self.mock_model, next_page_data)
|
|
366
|
-
)
|
|
367
|
-
|
|
368
|
-
expected_call_kwargs1 = {
|
|
369
|
-
"Statement": expected_statement,
|
|
370
|
-
"NextToken": initial_ddb_token,
|
|
371
|
-
}
|
|
372
|
-
self.mock_dynamodb_client.execute_statement.assert_called_once_with(
|
|
373
|
-
**expected_call_kwargs1
|
|
374
|
-
)
|
|
375
|
-
self.assertEqual(len(results_page1), 1)
|
|
376
|
-
self.assertEqual(results_page1[0], {"event_id": "evt1"})
|
|
377
|
-
client_token_for_next_call = next_page_data["next_token"]
|
|
378
|
-
self.assertIsNotNone(client_token_for_next_call)
|
|
379
|
-
self.assertEqual(
|
|
380
|
-
self.backend.restore_next_token_from_config(client_token_for_next_call),
|
|
381
|
-
ddb_next_token_page1,
|
|
382
|
-
)
|
|
383
|
-
|
|
384
|
-
self.mock_dynamodb_client.execute_statement.reset_mock()
|
|
385
|
-
config2 = self._get_base_config(
|
|
386
|
-
table_name="events",
|
|
387
|
-
select_all=True,
|
|
388
|
-
pagination={"next_token": client_token_for_next_call},
|
|
389
|
-
)
|
|
390
|
-
ddb_items_page2 = [{"event_id": {"S": "evt2"}}]
|
|
391
|
-
self.mock_dynamodb_client.execute_statement.return_value = {
|
|
392
|
-
"Items": ddb_items_page2
|
|
393
|
-
}
|
|
394
|
-
next_page_data = {}
|
|
395
|
-
results_page2 = list(
|
|
396
|
-
self.backend.records(config2, self.mock_model, next_page_data)
|
|
397
|
-
)
|
|
398
|
-
expected_call_kwargs2 = {
|
|
399
|
-
"Statement": expected_statement,
|
|
400
|
-
"NextToken": ddb_next_token_page1,
|
|
401
|
-
}
|
|
402
|
-
self.mock_dynamodb_client.execute_statement.assert_called_once_with(
|
|
403
|
-
**expected_call_kwargs2
|
|
404
|
-
)
|
|
405
|
-
self.assertEqual(len(results_page2), 1)
|
|
406
|
-
self.assertEqual(results_page2[0], {"event_id": "evt2"})
|
|
407
|
-
self.assertEqual(next_page_data, {})
|
|
408
|
-
|
|
409
|
-
def test_records_no_items_returned_with_next_token(self, mock_logger_arg):
|
|
410
|
-
"""Test records() when DDB returns no items but provides a NextToken."""
|
|
411
|
-
config = self._get_base_config(table_name="filtered_items", select_all=True)
|
|
412
|
-
expected_statement = 'SELECT * FROM "filtered_items"'
|
|
413
|
-
ddb_next_token = "ddb_has_more_but_current_page_empty_after_filter"
|
|
414
|
-
|
|
415
|
-
self.mock_dynamodb_client.execute_statement.return_value = {
|
|
416
|
-
"Items": [],
|
|
417
|
-
"NextToken": ddb_next_token,
|
|
418
|
-
}
|
|
419
|
-
next_page_data = {}
|
|
420
|
-
results = list(self.backend.records(config, self.mock_model, next_page_data))
|
|
421
|
-
|
|
422
|
-
expected_call_kwargs = {"Statement": expected_statement}
|
|
423
|
-
self.mock_dynamodb_client.execute_statement.assert_called_once_with(
|
|
424
|
-
**expected_call_kwargs
|
|
425
|
-
)
|
|
426
|
-
self.assertEqual(len(results), 0)
|
|
427
|
-
expected_client_token = self.backend.serialize_next_token_for_response(
|
|
428
|
-
ddb_next_token
|
|
429
|
-
)
|
|
430
|
-
self.assertEqual(
|
|
431
|
-
next_page_data["next_token"],
|
|
432
|
-
expected_client_token,
|
|
433
|
-
)
|
|
434
|
-
|
|
435
|
-
def test_records_limit_cuts_off_ddb_page(self, mock_logger_arg):
|
|
436
|
-
"""Test when server-side limit means fewer items are returned than a full DDB page."""
|
|
437
|
-
config = self._get_base_config(
|
|
438
|
-
table_name="many_items", limit=1, select_all=True
|
|
439
|
-
)
|
|
440
|
-
expected_statement = 'SELECT * FROM "many_items"'
|
|
441
|
-
ddb_items_returned_by_limit = [{"id": {"S": "item1"}}]
|
|
442
|
-
ddb_next_token_after_limit = "ddb_still_has_more_after_limit"
|
|
443
|
-
|
|
444
|
-
self.mock_dynamodb_client.execute_statement.return_value = {
|
|
445
|
-
"Items": ddb_items_returned_by_limit,
|
|
446
|
-
"NextToken": ddb_next_token_after_limit,
|
|
447
|
-
}
|
|
448
|
-
next_page_data = {}
|
|
449
|
-
results = list(self.backend.records(config, self.mock_model, next_page_data))
|
|
450
|
-
|
|
451
|
-
expected_call_kwargs = {
|
|
452
|
-
"Statement": expected_statement,
|
|
453
|
-
"Limit": 1,
|
|
454
|
-
}
|
|
455
|
-
self.mock_dynamodb_client.execute_statement.assert_called_once_with(
|
|
456
|
-
**expected_call_kwargs
|
|
457
|
-
)
|
|
458
|
-
self.assertEqual(len(results), 1)
|
|
459
|
-
self.assertEqual(results[0], {"id": "item1"})
|
|
460
|
-
expected_client_token = self.backend.serialize_next_token_for_response(
|
|
461
|
-
ddb_next_token_after_limit
|
|
462
|
-
)
|
|
463
|
-
self.assertEqual(
|
|
464
|
-
next_page_data["next_token"],
|
|
465
|
-
expected_client_token,
|
|
466
|
-
)
|
|
467
|
-
|
|
468
|
-
def test_create_record(self, mock_logger_arg):
|
|
469
|
-
"""Test create() inserts a record and returns the input data."""
|
|
470
|
-
data_to_create = {"id": "new_user_123", "name": "Jane Doe", "age": 28}
|
|
471
|
-
# Updated expected statement and parameters to match the new PartiQL format
|
|
472
|
-
expected_statement = (
|
|
473
|
-
"INSERT INTO \"my_test_table\" VALUE {'id': ?, 'name': ?, 'age': ?}"
|
|
474
|
-
)
|
|
475
|
-
expected_ddb_parameters = [
|
|
476
|
-
{"S": "new_user_123"},
|
|
477
|
-
{"S": "Jane Doe"},
|
|
478
|
-
{"N": "28"},
|
|
479
|
-
]
|
|
480
|
-
|
|
481
|
-
self.mock_dynamodb_client.execute_statement.return_value = {}
|
|
482
|
-
|
|
483
|
-
created_data = self.backend.create(data_to_create, self.mock_model)
|
|
484
|
-
|
|
485
|
-
self.assertEqual(created_data, data_to_create)
|
|
486
|
-
self.mock_dynamodb_client.execute_statement.assert_called_once_with(
|
|
487
|
-
Statement=expected_statement,
|
|
488
|
-
Parameters=expected_ddb_parameters,
|
|
489
|
-
)
|
|
490
|
-
|
|
491
|
-
def test_update_record(self, mock_logger_arg):
|
|
492
|
-
"""Test update() modifies a record and returns the updated data."""
|
|
493
|
-
record_id = "user_to_update"
|
|
494
|
-
update_data = {"age": 35, "status": "active"}
|
|
495
|
-
|
|
496
|
-
expected_set_params = [{"N": "35"}, {"S": "active"}]
|
|
497
|
-
expected_id_param = {"S": "user_to_update"}
|
|
498
|
-
expected_ddb_parameters = expected_set_params + [expected_id_param]
|
|
499
|
-
|
|
500
|
-
updated_item_from_db = {
|
|
501
|
-
"id": {"S": record_id},
|
|
502
|
-
"name": {"S": "Original Name"},
|
|
503
|
-
"age": {"N": "35"},
|
|
504
|
-
"status": {"S": "active"},
|
|
505
|
-
}
|
|
506
|
-
self.mock_dynamodb_client.execute_statement.return_value = {
|
|
507
|
-
"Items": [updated_item_from_db]
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
updated_data_response = self.backend.update(
|
|
511
|
-
record_id, update_data, self.mock_model
|
|
512
|
-
)
|
|
513
|
-
|
|
514
|
-
expected_statement = 'UPDATE "my_test_table" SET "age" = ?, "status" = ? WHERE "id" = ? RETURNING ALL NEW *'
|
|
515
|
-
self.mock_dynamodb_client.execute_statement.assert_called_once_with(
|
|
516
|
-
Statement=expected_statement, Parameters=expected_ddb_parameters
|
|
517
|
-
)
|
|
518
|
-
self.assertEqual(
|
|
519
|
-
updated_data_response,
|
|
520
|
-
{
|
|
521
|
-
"id": "user_to_update",
|
|
522
|
-
"name": "Original Name",
|
|
523
|
-
"age": Decimal("35"),
|
|
524
|
-
"status": "active",
|
|
525
|
-
},
|
|
526
|
-
)
|
|
527
|
-
|
|
528
|
-
def test_delete_record(self, mock_logger_arg):
|
|
529
|
-
"""Test delete() removes a record."""
|
|
530
|
-
record_id = "user_to_delete"
|
|
531
|
-
expected_ddb_parameters = [{"S": "user_to_delete"}]
|
|
532
|
-
self.mock_dynamodb_client.execute_statement.return_value = {}
|
|
533
|
-
|
|
534
|
-
result = self.backend.delete(record_id, self.mock_model)
|
|
535
|
-
|
|
536
|
-
self.assertTrue(result)
|
|
537
|
-
self.mock_dynamodb_client.execute_statement.assert_called_once_with(
|
|
538
|
-
Statement='DELETE FROM "my_test_table" WHERE "id" = ?',
|
|
539
|
-
Parameters=expected_ddb_parameters,
|
|
540
|
-
)
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
if __name__ == "__main__":
|
|
544
|
-
unittest.main(argv=["first-arg-is-ignored"], exit=False)
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
from clearskies.backends.backend import Backend
|
|
2
|
-
from clearskies import model
|
|
3
|
-
import json
|
|
4
|
-
from typing import Any, Callable, Dict, List, Tuple
|
|
5
|
-
class SqsBackend(Backend):
|
|
6
|
-
"""
|
|
7
|
-
SQS backend for clearskies
|
|
8
|
-
|
|
9
|
-
There's not too much to this. Just set it on your model and set the table name equal to the SQS url.
|
|
10
|
-
|
|
11
|
-
This doesn't support setting message attributes. The SQS call is simple enough that if you need
|
|
12
|
-
those you may as well just invoke the boto3 SDK yourself.
|
|
13
|
-
|
|
14
|
-
Note that this is a *write-only* backend. Reading from an SQS queue is different enough from
|
|
15
|
-
the way that clearskies models works that it doesn't make sense to try to make those happen here.
|
|
16
|
-
|
|
17
|
-
See the SQS context in this library for processing your queue data.
|
|
18
|
-
"""
|
|
19
|
-
|
|
20
|
-
_boto3 = None
|
|
21
|
-
_environment = None
|
|
22
|
-
_sqs = None
|
|
23
|
-
|
|
24
|
-
_allowed_configs = [
|
|
25
|
-
'table_name',
|
|
26
|
-
'model_columns',
|
|
27
|
-
]
|
|
28
|
-
|
|
29
|
-
_required_configs = [
|
|
30
|
-
'table_name',
|
|
31
|
-
]
|
|
32
|
-
|
|
33
|
-
def __init__(self, boto3, environment):
|
|
34
|
-
self._boto3 = boto3
|
|
35
|
-
self._environment = environment
|
|
36
|
-
if not environment.get('AWS_REGION', True):
|
|
37
|
-
raise ValueError('To use SQS you must use set AWS_REGION in the .env file or an environment variable')
|
|
38
|
-
|
|
39
|
-
self._sqs = self._boto3.client('sqs', region_name=environment.get('AWS_REGION', True))
|
|
40
|
-
|
|
41
|
-
def configure(self):
|
|
42
|
-
pass
|
|
43
|
-
|
|
44
|
-
def create(self, data, model):
|
|
45
|
-
self._sqs.send_message(
|
|
46
|
-
QueueUrl=model.table_name(),
|
|
47
|
-
MessageBody=json.dumps(data),
|
|
48
|
-
)
|
|
49
|
-
return {**data}
|
|
50
|
-
|
|
51
|
-
def update(self, id, data, model):
|
|
52
|
-
raise ValueError("The SQS backend only supports the create operation")
|
|
53
|
-
|
|
54
|
-
def delete(self, id, model):
|
|
55
|
-
raise ValueError("The SQS backend only supports the create operation")
|
|
56
|
-
|
|
57
|
-
def count(self, configuration, model):
|
|
58
|
-
raise ValueError("The SQS backend only supports the create operation")
|
|
59
|
-
|
|
60
|
-
def records(self,
|
|
61
|
-
configuration: Dict[str, Any],
|
|
62
|
-
model: model.Model,
|
|
63
|
-
next_page_data: Dict[str, str] = None) -> List[Dict[str, Any]]:
|
|
64
|
-
raise ValueError("The SQS backend only supports the create operation")
|
|
65
|
-
return []
|
|
66
|
-
|
|
67
|
-
def validate_pagination_kwargs(self, kwargs: Dict[str, Any], case_mapping: Callable) -> str:
|
|
68
|
-
return ''
|
|
69
|
-
|
|
70
|
-
def allowed_pagination_keys(self) -> List[str]:
|
|
71
|
-
return []
|
|
72
|
-
|
|
73
|
-
def documentation_pagination_next_page_response(self, case_mapping: Callable) -> List[Any]:
|
|
74
|
-
return []
|
|
75
|
-
|
|
76
|
-
def documentation_pagination_next_page_example(self, case_mapping: Callable) -> Dict[str, Any]:
|
|
77
|
-
return {}
|
|
78
|
-
|
|
79
|
-
def documentation_pagination_parameters(self, case_mapping: Callable) -> List[Tuple[Any]]:
|
|
80
|
-
return []
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import unittest
|
|
2
|
-
import json
|
|
3
|
-
from unittest.mock import MagicMock
|
|
4
|
-
from collections import OrderedDict
|
|
5
|
-
from types import SimpleNamespace
|
|
6
|
-
from .sqs_backend import SqsBackend
|
|
7
|
-
import clearskies
|
|
8
|
-
from ..di import StandardDependencies
|
|
9
|
-
class User(clearskies.Model):
|
|
10
|
-
def __init__(self, sqs_backend, columns):
|
|
11
|
-
super().__init__(sqs_backend, columns)
|
|
12
|
-
|
|
13
|
-
id_column_name = 'name'
|
|
14
|
-
|
|
15
|
-
def columns_configuration(self):
|
|
16
|
-
return OrderedDict([
|
|
17
|
-
clearskies.column_types.string('name'),
|
|
18
|
-
])
|
|
19
|
-
class SqsBackendTest(unittest.TestCase):
|
|
20
|
-
def setUp(self):
|
|
21
|
-
self.di = StandardDependencies()
|
|
22
|
-
self.di.bind('environment', {'AWS_REGION': 'us-east-2'})
|
|
23
|
-
self.sqs = SimpleNamespace(send_message=MagicMock())
|
|
24
|
-
self.boto3 = SimpleNamespace(client=MagicMock(return_value=self.sqs))
|
|
25
|
-
self.di.bind('boto3', self.boto3)
|
|
26
|
-
|
|
27
|
-
def test_send(self):
|
|
28
|
-
user = self.di.build(User)
|
|
29
|
-
user.save({'name': 'sup'})
|
|
30
|
-
self.boto3.client.assert_called_with('sqs', region_name='us-east-2')
|
|
31
|
-
self.sqs.send_message.assert_called_with(QueueUrl='users', MessageBody=json.dumps({"name": "sup"}))
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
from .cli import cli
|
|
2
|
-
from .cli_websocket_mock import cli_websocket_mock
|
|
3
|
-
from .lambda_api_gateway import lambda_api_gateway
|
|
4
|
-
from .lambda_api_gateway_web_socket import lambda_api_gateway_web_socket
|
|
5
|
-
from .lambda_elb import lambda_elb
|
|
6
|
-
from .lambda_http_gateway import lambda_http_gateway
|
|
7
|
-
from .lambda_invocation import lambda_invocation
|
|
8
|
-
from .lambda_sqs_standard_partial_batch import lambda_sqs_standard_partial_batch
|
|
9
|
-
from .lambda_sns import lambda_sns
|
|
10
|
-
from .wsgi import wsgi
|