modmex-lambda 0.5.2__py3-none-any.whl → 0.5.4__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.
@@ -19,53 +19,60 @@ class Connector(IDynamodbConnector):
19
19
  self.table_name = table_name
20
20
  self._client = client
21
21
  self.retry_config = retry_config
22
+ self._serializer = None
23
+ self._deserializer = None
22
24
 
23
25
  @property
24
26
  def client(self):
25
27
  if not self._client:
26
28
  import boto3
27
- self._client = boto3.resource('dynamodb')
29
+ self._client = boto3.client('dynamodb')
28
30
  return self._client
29
31
 
30
32
  def get(self, input_params):
31
- return self.client.Table(self.table_name).get_item(
32
- **input_params
33
+ response = self.client.get_item(
34
+ TableName=self.table_name,
35
+ **self._marshall_request(input_params),
33
36
  )
37
+ return self._unmarshall_response(response)
34
38
 
35
39
  def update(self, input_params):
36
- return self.client.Table(self.table_name).update_item(
37
- **input_params
40
+ response = self.client.update_item(
41
+ TableName=self.table_name,
42
+ **self._marshall_request(input_params),
38
43
  )
44
+ return self._unmarshall_response(response)
39
45
 
40
46
  def put(self, input_params):
41
- return self.client.Table(self.table_name).put_item(
42
- **input_params
47
+ response = self.client.put_item(
48
+ TableName=self.table_name,
49
+ **self._marshall_request(input_params),
43
50
  )
51
+ return self._unmarshall_response(response)
44
52
 
45
53
  def query(self, input_params):
46
- return self.client.Table(self.table_name).query(
47
- **input_params
54
+ response = self.client.query(
55
+ TableName=self.table_name,
56
+ **self._marshall_request(input_params),
48
57
  )
58
+ return self._unmarshall_response(response)
49
59
 
50
60
  def query_page(self, input_params):
51
61
  return self.query(input_params)
52
62
 
53
63
  def scan(self, input_params):
54
- return self.client.Table(self.table_name).scan(
55
- **input_params
64
+ response = self.client.scan(
65
+ TableName=self.table_name,
66
+ **self._marshall_request(input_params),
56
67
  )
68
+ return self._unmarshall_response(response)
57
69
 
58
70
  def query_all(self, input_params):
59
71
  items = []
60
72
  while True:
61
-
62
- result = self.client.Table(self.table_name).query(
63
- **input_params
64
- )
65
- for item in result['Items']:
66
- items.append(item)
67
-
68
- if 'LastEvaluatedKey' in result and result['LastEvaluatedKey']:
73
+ result = self.query(input_params)
74
+ items.extend(result.get('Items', []))
75
+ if result.get('LastEvaluatedKey'):
69
76
  input_params['ExclusiveStartKey'] = result['LastEvaluatedKey']
70
77
  else:
71
78
  break
@@ -77,23 +84,175 @@ class Connector(IDynamodbConnector):
77
84
  def _batch_get(self, params, attempts):
78
85
  assert_max_retries(attempts, self.retry_config['max_retries'])
79
86
  wait(get_delay(self.retry_config['retry_wait'], len(attempts)))
80
- resp = self.client.batch_get_item(**params)
81
- if 'UnprocessedKeys' in resp and resp['UnprocessedKeys']:
87
+ response = self.client.batch_get_item(**self._marshall_batch_get_request(params))
88
+ response = self._unmarshall_response(response)
89
+ if response.get('UnprocessedKeys'):
82
90
  return self._batch_get(
83
- unprocessed(params, resp),
84
- [*attempts, resp]
91
+ unprocessed(params, response),
92
+ [*attempts, response]
85
93
  )
86
- return accumulate(attempts, resp)
94
+ return accumulate(attempts, response)
87
95
 
88
96
  def bulk_insert(self, items: Iterable):
89
- with self.client.Table(self.table_name).batch_writer() as batch:
90
- for item in items:
91
- batch.put_item(Item=item)
97
+ self._batch_write([
98
+ {
99
+ 'PutRequest': {
100
+ 'Item': item,
101
+ },
102
+ }
103
+ for item in items
104
+ ])
92
105
 
93
106
  def bulk_delete(self, items: Iterable):
94
- with self.client.Table(self.table_name).batch_writer() as batch:
95
- for key in items:
96
- batch.delete_item(Key=key)
107
+ self._batch_write([
108
+ {
109
+ 'DeleteRequest': {
110
+ 'Key': key,
111
+ },
112
+ }
113
+ for key in items
114
+ ])
115
+
116
+ def _marshall_request(self, params):
117
+ return {
118
+ key: self._marshall_value(key, value)
119
+ for key, value in params.items()
120
+ }
121
+
122
+ def _marshall_batch_get_request(self, params):
123
+ return {
124
+ **params,
125
+ 'RequestItems': {
126
+ table_name: {
127
+ **request,
128
+ 'Keys': [
129
+ self._marshall_item(key)
130
+ for key in request.get('Keys', [])
131
+ ],
132
+ }
133
+ for table_name, request in params.get('RequestItems', {}).items()
134
+ },
135
+ }
136
+
137
+ def _marshall_value(self, key, value):
138
+ if key in {'Key', 'Item', 'LastEvaluatedKey', 'ExclusiveStartKey'}:
139
+ return self._marshall_item(value)
140
+ if key == 'ExpressionAttributeValues':
141
+ return {
142
+ attr: self.serializer.serialize(attr_value)
143
+ for attr, attr_value in value.items()
144
+ }
145
+ return value
146
+
147
+ def _marshall_item(self, item):
148
+ return {
149
+ key: self.serializer.serialize(value)
150
+ for key, value in item.items()
151
+ }
152
+
153
+ def _unmarshall_response(self, response):
154
+ return {
155
+ key: self._unmarshall_value(key, value)
156
+ for key, value in response.items()
157
+ }
158
+
159
+ def _unmarshall_value(self, key, value):
160
+ if key in {'Item', 'LastEvaluatedKey'}:
161
+ return self._unmarshall_item(value)
162
+ if key == 'Items':
163
+ return [self._unmarshall_item(item) for item in value]
164
+ if key == 'Attributes':
165
+ return self._unmarshall_item(value)
166
+ if key == 'Responses':
167
+ return {
168
+ table_name: [self._unmarshall_item(item) for item in items]
169
+ for table_name, items in value.items()
170
+ }
171
+ if key == 'UnprocessedKeys':
172
+ return {
173
+ table_name: {
174
+ **request,
175
+ 'Keys': [self._unmarshall_item(item) for item in request.get('Keys', [])],
176
+ }
177
+ for table_name, request in value.items()
178
+ }
179
+ return value
180
+
181
+ def _unmarshall_item(self, item):
182
+ return {
183
+ key: self.deserializer.deserialize(value)
184
+ for key, value in item.items()
185
+ }
186
+
187
+ @property
188
+ def serializer(self):
189
+ if not self._serializer:
190
+ self._load_dynamodb_types()
191
+ return self._serializer
192
+
193
+ @property
194
+ def deserializer(self):
195
+ if not self._deserializer:
196
+ self._load_dynamodb_types()
197
+ return self._deserializer
198
+
199
+ def _load_dynamodb_types(self):
200
+ from boto3.dynamodb.types import TypeDeserializer, TypeSerializer
201
+ self._serializer = TypeSerializer()
202
+ self._deserializer = TypeDeserializer()
203
+
204
+ def _batch_write(self, requests):
205
+ for index in range(0, len(requests), 25):
206
+ self._batch_write_chunk(requests[index:index + 25], [])
207
+
208
+ def _batch_write_chunk(self, requests, attempts):
209
+ if not requests:
210
+ return
211
+ assert_max_retries(attempts, self.retry_config['max_retries'])
212
+ wait(get_delay(self.retry_config['retry_wait'], len(attempts)))
213
+ response = self.client.batch_write_item(
214
+ RequestItems={
215
+ self.table_name: [
216
+ self._marshall_write_request(request)
217
+ for request in requests
218
+ ],
219
+ },
220
+ )
221
+ unprocessed_items = response.get('UnprocessedItems', {}).get(self.table_name, [])
222
+ if unprocessed_items:
223
+ self._batch_write_chunk(
224
+ [
225
+ self._unmarshall_write_request(request)
226
+ for request in unprocessed_items
227
+ ],
228
+ [*attempts, response],
229
+ )
230
+
231
+ def _marshall_write_request(self, request):
232
+ if 'PutRequest' in request:
233
+ return {
234
+ 'PutRequest': {
235
+ 'Item': self._marshall_item(request['PutRequest']['Item']),
236
+ },
237
+ }
238
+ return {
239
+ 'DeleteRequest': {
240
+ 'Key': self._marshall_item(request['DeleteRequest']['Key']),
241
+ },
242
+ }
243
+
244
+ def _unmarshall_write_request(self, request):
245
+ if 'PutRequest' in request:
246
+ return {
247
+ 'PutRequest': {
248
+ 'Item': self._unmarshall_item(request['PutRequest']['Item']),
249
+ },
250
+ }
251
+ return {
252
+ 'DeleteRequest': {
253
+ 'Key': self._unmarshall_item(request['DeleteRequest']['Key']),
254
+ },
255
+ }
97
256
 
98
257
 
99
258
  def unprocessed(params, resp):
@@ -17,9 +17,12 @@ class AwsConnectorsModule(Module):
17
17
  @singleton
18
18
  @provider
19
19
  def provide_dynamodb(self) -> IDynamodbConnector:
20
+ import os
20
21
  from modmex_lambda.connectors.dynamodb import Connector
21
22
 
22
- return Connector()
23
+ return Connector(
24
+ table_name=os.getenv('ENTITY_TABLE_NAME') or os.getenv('EVENT_TABLE_NAME'),
25
+ )
23
26
 
24
27
  @singleton
25
28
  @provider
@@ -0,0 +1,2 @@
1
+ """Persistence helpers for common modmex-lambda application patterns."""
2
+
@@ -0,0 +1,39 @@
1
+ """DynamoDB persistence helpers."""
2
+
3
+ from modmex_lambda.persistence.dynamodb.expressions import (
4
+ pk_condition,
5
+ timestamp_condition,
6
+ update_expression,
7
+ )
8
+ from modmex_lambda.persistence.dynamodb.keys import (
9
+ AggregateKeyStrategy,
10
+ KeyStrategy,
11
+ SingleEntityKeyStrategy,
12
+ TenantPartitionKeyStrategy,
13
+ TenantScopedSortKeyStrategy,
14
+ )
15
+ from modmex_lambda.persistence.dynamodb.materialized_views import (
16
+ DynamoDBUpdateRequestMixin,
17
+ MaterializedViewMixin,
18
+ )
19
+ from modmex_lambda.persistence.dynamodb.stream_fields import (
20
+ DefaultStreamFieldsStrategy,
21
+ StreamFieldsStrategy,
22
+ stream_entity_fields,
23
+ )
24
+
25
+ __all__ = [
26
+ "AggregateKeyStrategy",
27
+ "DynamoDBUpdateRequestMixin",
28
+ "KeyStrategy",
29
+ "MaterializedViewMixin",
30
+ "SingleEntityKeyStrategy",
31
+ "DefaultStreamFieldsStrategy",
32
+ "StreamFieldsStrategy",
33
+ "TenantPartitionKeyStrategy",
34
+ "TenantScopedSortKeyStrategy",
35
+ "pk_condition",
36
+ "stream_entity_fields",
37
+ "timestamp_condition",
38
+ "update_expression",
39
+ ]
@@ -0,0 +1,62 @@
1
+ from functools import reduce
2
+
3
+
4
+ def update_expression(item: dict):
5
+ keys = item.keys()
6
+ result = {}
7
+ result['ExpressionAttributeNames'] = reduce(
8
+ lambda accumulator, el: {**accumulator, **el},
9
+ map(
10
+ lambda attrName: {f"#{attrName}": attrName },
11
+ keys
12
+ ),
13
+ {}
14
+ )
15
+ result['ExpressionAttributeValues'] = reduce(
16
+ lambda accumulator, el: {**accumulator, **el},
17
+ map(
18
+ lambda attrName: {f":{attrName}": item[attrName] },
19
+ filter(
20
+ lambda attrName: item[attrName] is not None,
21
+ keys
22
+ )
23
+ ),
24
+ {}
25
+ )
26
+ result['UpdateExpression'] = "SET "+", ".join(map(
27
+ lambda attrName: f"#{attrName} = :{attrName}",
28
+ filter(
29
+ lambda attrName: item[attrName] is not None,
30
+ keys
31
+ )
32
+ ))
33
+ update_expression_remove = ", ".join(map(
34
+ lambda attrName: f"#{attrName}",
35
+ filter(
36
+ lambda attrName: item[attrName] is None,
37
+ keys
38
+ )
39
+ ))
40
+ if update_expression_remove:
41
+ result['UpdateExpression'] = "{} REMOVE {}".format(
42
+ result['UpdateExpression'],
43
+ update_expression_remove
44
+ )
45
+ result['ReturnValues'] = 'ALL_NEW'
46
+ return result
47
+
48
+
49
+ def timestamp_condition(field_name = 'timestamp'):
50
+ return {
51
+ 'ConditionExpression': "attribute_not_exists(#{fn}) OR #{fn} < :{fn}".format(
52
+ fn=field_name
53
+ )
54
+ }
55
+
56
+
57
+ def pk_condition(field_name = 'pk'):
58
+ return {
59
+ 'ConditionExpression': "attribute_not_exists({})".format(
60
+ field_name
61
+ )
62
+ }
@@ -0,0 +1,133 @@
1
+ """Reusable DynamoDB key strategies.
2
+
3
+ These helpers keep key-shaping decisions explicit while letting repositories
4
+ stay focused on persistence behavior.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from abc import ABC, abstractmethod
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+
14
+ class KeyStrategy(ABC):
15
+ """Build DynamoDB primary keys for ids and entities."""
16
+
17
+ @abstractmethod
18
+ def key_for_id(self, entity_id: Any, **context: Any) -> dict[str, str]:
19
+ raise NotImplementedError
20
+
21
+ @abstractmethod
22
+ def key_for_entity(self, entity: Any, **context: Any) -> dict[str, str]:
23
+ raise NotImplementedError
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class SingleEntityKeyStrategy(KeyStrategy):
28
+ """Use the entity id as pk and a fixed discriminator as sk."""
29
+
30
+ discriminator: str
31
+
32
+ def key_for_id(self, entity_id: Any, **context: Any) -> dict[str, str]:
33
+ return {
34
+ "pk": str(entity_id),
35
+ "sk": self.discriminator,
36
+ }
37
+
38
+ def key_for_entity(self, entity: Any, **context: Any) -> dict[str, str]:
39
+ return self.key_for_id(_entity_attr(entity, "id"), **context)
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class TenantScopedSortKeyStrategy(KeyStrategy):
44
+ """Use entity id as pk and discriminator plus tenant id as sk."""
45
+
46
+ discriminator: str
47
+ separator: str = "#"
48
+ tenant_field: str = "tenant_id"
49
+
50
+ def key_for_id(self, entity_id: Any, **context: Any) -> dict[str, str]:
51
+ tenant_id = _context_value(context, self.tenant_field)
52
+ return {
53
+ "pk": str(entity_id),
54
+ "sk": self._sort_key(tenant_id),
55
+ }
56
+
57
+ def key_for_entity(self, entity: Any, **context: Any) -> dict[str, str]:
58
+ tenant_id = _context_or_entity_value(context, entity, self.tenant_field)
59
+ return self.key_for_id(_entity_attr(entity, "id"), **{self.tenant_field: tenant_id})
60
+
61
+ def _sort_key(self, tenant_id: Any) -> str:
62
+ return f"{self.discriminator}{self.separator}{tenant_id}"
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class TenantPartitionKeyStrategy(KeyStrategy):
67
+ """Use tenant id as pk and discriminator plus entity id as sk."""
68
+
69
+ discriminator: str
70
+ separator: str = "#"
71
+ tenant_field: str = "tenant_id"
72
+
73
+ def key_for_id(self, entity_id: Any, **context: Any) -> dict[str, str]:
74
+ tenant_id = _context_value(context, self.tenant_field)
75
+ return {
76
+ "pk": str(tenant_id),
77
+ "sk": self._sort_key(entity_id),
78
+ }
79
+
80
+ def key_for_entity(self, entity: Any, **context: Any) -> dict[str, str]:
81
+ tenant_id = _context_or_entity_value(context, entity, self.tenant_field)
82
+ return self.key_for_id(_entity_attr(entity, "id"), **{self.tenant_field: tenant_id})
83
+
84
+ def _sort_key(self, entity_id: Any) -> str:
85
+ return f"{self.discriminator}{self.separator}{entity_id}"
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class AggregateKeyStrategy(KeyStrategy):
90
+ """Use aggregate id as pk and entity name plus entity id as sk."""
91
+
92
+ aggregate_name: str
93
+ entity_name: str
94
+ separator: str = "#"
95
+ aggregate_field: str = "aggregate_id"
96
+
97
+ def key_for_id(self, entity_id: Any, **context: Any) -> dict[str, str]:
98
+ aggregate_id = _context_value(context, self.aggregate_field)
99
+ return {
100
+ "pk": f"{self.aggregate_name}{self.separator}{aggregate_id}",
101
+ "sk": f"{self.entity_name}{self.separator}{entity_id}",
102
+ }
103
+
104
+ def key_for_entity(self, entity: Any, **context: Any) -> dict[str, str]:
105
+ aggregate_id = _context_or_entity_value(context, entity, self.aggregate_field)
106
+ return self.key_for_id(_entity_attr(entity, "id"), **{self.aggregate_field: aggregate_id})
107
+
108
+
109
+ def _context_value(context: dict[str, Any], field_name: str) -> Any:
110
+ if field_name in context and context[field_name] is not None:
111
+ return context[field_name]
112
+ raise KeyError(f"Missing required key context: {field_name}")
113
+
114
+
115
+ def _context_or_entity_value(context: dict[str, Any], entity: Any, field_name: str) -> Any:
116
+ if field_name in context and context[field_name] is not None:
117
+ return context[field_name]
118
+ value = _entity_attr(entity, field_name)
119
+ if value is not None:
120
+ return value
121
+ raise AttributeError(f"Entity is missing required field: {field_name}")
122
+
123
+
124
+ def _entity_attr(entity: Any, field_name: str) -> Any:
125
+ if isinstance(entity, dict):
126
+ try:
127
+ return entity[field_name]
128
+ except KeyError as exc:
129
+ raise AttributeError(f"Entity is missing required field: {field_name}") from exc
130
+ try:
131
+ return getattr(entity, field_name)
132
+ except AttributeError as exc:
133
+ raise AttributeError(f"Entity is missing required field: {field_name}") from exc
@@ -0,0 +1,80 @@
1
+ """DynamoDB helpers for materialized view update requests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Callable
6
+
7
+ from modmex_lambda.persistence.dynamodb.expressions import timestamp_condition, update_expression
8
+ from modmex_lambda.persistence.dynamodb.stream_fields import stream_entity_fields
9
+ from modmex_lambda.stream.utils.time import ttl as stream_ttl
10
+
11
+
12
+ class DynamoDBUpdateRequestMixin:
13
+ """Build DynamoDB UpdateItem requests with a timestamp guard by default."""
14
+
15
+ def build_update_request(
16
+ self,
17
+ *,
18
+ key: dict[str, Any],
19
+ fields: dict[str, Any],
20
+ timestamp_condition_enabled: bool = True,
21
+ ) -> dict[str, Any]:
22
+ request = {
23
+ "Key": key,
24
+ **update_expression(fields),
25
+ }
26
+
27
+ if timestamp_condition_enabled:
28
+ request.update(timestamp_condition())
29
+
30
+ return request
31
+
32
+
33
+ class MaterializedViewMixin(DynamoDBUpdateRequestMixin):
34
+ """Map stream unit-of-work events into DynamoDB materialized view updates."""
35
+
36
+ discriminator: str
37
+ materialized_source_name: str | None = None
38
+ materialized_last_modified_by: str | None = "system"
39
+ use_ttl: bool = False
40
+ days_ttl: int = 30
41
+
42
+ @classmethod
43
+ def materialized_update_request_mapper(cls) -> Callable[[dict[str, Any]], dict[str, Any]]:
44
+ materializer = cls.__new__(cls)
45
+ return materializer.to_materialized_update_request
46
+
47
+ def to_materialized_update_request(self, uow: dict[str, Any]) -> dict[str, Any]:
48
+ entity_name = self.materialized_source_name or self.discriminator
49
+ entity = uow["event"][entity_name]
50
+
51
+ return self.build_update_request(
52
+ key=self.materialized_key(uow, entity),
53
+ fields=self.materialized_fields(uow, entity),
54
+ )
55
+
56
+ def materialized_key(self, uow: dict[str, Any], entity: dict[str, Any]) -> dict[str, Any]:
57
+ return {
58
+ "pk": entity["id"],
59
+ "sk": entity.get("sk", self.discriminator),
60
+ }
61
+
62
+ def materialized_fields(self, uow: dict[str, Any], entity: dict[str, Any]) -> dict[str, Any]:
63
+ timestamp = uow["event"]["timestamp"]
64
+ source_name = self.materialized_source_name or self.discriminator
65
+ fields = {
66
+ **{key: value for key, value in entity.items() if key not in ["pk", "sk"]},
67
+ **stream_entity_fields(
68
+ self.discriminator,
69
+ timestamp=timestamp,
70
+ deleted=True if uow["event"]["type"] == f"{source_name}-deleted" else None,
71
+ latched=True,
72
+ ttl=stream_ttl(timestamp, self.days_ttl) if self.use_ttl else None,
73
+ ),
74
+ }
75
+ if self.materialized_last_modified_by is not None:
76
+ fields["last_modified_by"] = self.materialized_last_modified_by
77
+ return fields
78
+
79
+
80
+ __all__ = ["DynamoDBUpdateRequestMixin", "MaterializedViewMixin"]
@@ -0,0 +1,121 @@
1
+ """Fields used by modmex-lambda stream-compatible DynamoDB items."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from abc import ABC, abstractmethod
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+ from modmex_lambda.stream.utils.time import now, ttl as stream_ttl
11
+
12
+
13
+ def stream_entity_fields(
14
+ discriminator: str,
15
+ *,
16
+ timestamp: int,
17
+ deleted: bool | None = None,
18
+ latched: bool = False,
19
+ ttl: int | None = None,
20
+ awsregion: str | None = None,
21
+ ) -> dict[str, Any]:
22
+ """Build standard fields consumed by modmex-lambda stream processors."""
23
+
24
+ fields = {
25
+ "discriminator": discriminator,
26
+ "deleted": deleted,
27
+ "latched": latched,
28
+ "ttl": ttl,
29
+ "awsregion": awsregion if awsregion is not None else os.getenv("REGION"),
30
+ "timestamp": timestamp,
31
+ }
32
+ return fields
33
+
34
+
35
+ class StreamFieldsStrategy(ABC):
36
+ """Build stream-compatible item fields for DynamoDB writes."""
37
+
38
+ @abstractmethod
39
+ def fields_for_save(
40
+ self,
41
+ data: dict[str, Any],
42
+ *,
43
+ timestamp: int | None = None,
44
+ ttl: int | None = None,
45
+ ) -> dict[str, Any]:
46
+ raise NotImplementedError
47
+
48
+ @abstractmethod
49
+ def fields_for_delete(
50
+ self,
51
+ data: dict[str, Any],
52
+ *,
53
+ timestamp: int | None = None,
54
+ ttl: int | None = None,
55
+ ) -> dict[str, Any]:
56
+ raise NotImplementedError
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class DefaultStreamFieldsStrategy(StreamFieldsStrategy):
61
+ """Default stream field contract used by modmex-lambda stream processors."""
62
+
63
+ discriminator: str
64
+ key_fields: tuple[str, ...] = field(default=("pk", "sk"))
65
+ use_ttl: bool = False
66
+ days_ttl: int = 30
67
+
68
+ def fields_for_save(
69
+ self,
70
+ data: dict[str, Any],
71
+ *,
72
+ timestamp: int | None = None,
73
+ ttl: int | None = None,
74
+ ) -> dict[str, Any]:
75
+ timestamp = self._timestamp(timestamp)
76
+ return {
77
+ **self._without_key_fields(data),
78
+ **stream_entity_fields(
79
+ self.discriminator,
80
+ timestamp=timestamp,
81
+ deleted=None,
82
+ latched=False,
83
+ ttl=self._ttl(timestamp, ttl),
84
+ ),
85
+ }
86
+
87
+ def fields_for_delete(
88
+ self,
89
+ data: dict[str, Any],
90
+ *,
91
+ timestamp: int | None = None,
92
+ ttl: int | None = None,
93
+ ) -> dict[str, Any]:
94
+ timestamp = self._timestamp(timestamp)
95
+ return {
96
+ **self._without_key_fields(data),
97
+ **stream_entity_fields(
98
+ self.discriminator,
99
+ timestamp=timestamp,
100
+ deleted=True,
101
+ latched=False,
102
+ ttl=self._ttl(timestamp, ttl),
103
+ ),
104
+ }
105
+
106
+ def _without_key_fields(self, data: dict[str, Any]) -> dict[str, Any]:
107
+ return {
108
+ key: value
109
+ for key, value in data.items()
110
+ if key not in self.key_fields
111
+ }
112
+
113
+ def _timestamp(self, timestamp: int | None) -> int:
114
+ return timestamp if timestamp is not None else now()
115
+
116
+ def _ttl(self, timestamp: int, ttl: int | None) -> int | None:
117
+ if ttl is not None:
118
+ return ttl
119
+ if self.use_ttl:
120
+ return stream_ttl(timestamp, self.days_ttl)
121
+ return None
@@ -1,8 +1,12 @@
1
1
  import os
2
2
  from typing import Union
3
- from functools import reduce
4
3
  from boto3.dynamodb.types import TypeDeserializer, TypeSerializer
5
4
  from modmex_lambda.connectors.dynamodb import Connector
5
+ from modmex_lambda.persistence.dynamodb.expressions import (
6
+ pk_condition,
7
+ timestamp_condition,
8
+ update_expression,
9
+ )
6
10
  from modmex_lambda.stream.operators.dynamodb import (
7
11
  BatchGetDynamoDB,
8
12
  PutDynamoDB,
@@ -20,65 +24,6 @@ def serialize_number(number: str) -> Union[float, int]:
20
24
 
21
25
  setattr(TypeDeserializer, '_deserialize_n', lambda _, number: serialize_number(number))
22
26
 
23
-
24
- def update_expression(item: dict):
25
- keys = item.keys()
26
- result = {}
27
- result['ExpressionAttributeNames'] = reduce(
28
- lambda accumulator, el: {**accumulator, **el},
29
- map(
30
- lambda attrName: {f"#{attrName}": attrName },
31
- keys
32
- ),
33
- {}
34
- )
35
- result['ExpressionAttributeValues'] = reduce(
36
- lambda accumulator, el: {**accumulator, **el},
37
- map(
38
- lambda attrName: {f":{attrName}": item[attrName] },
39
- filter(
40
- lambda attrName: item[attrName] is not None,
41
- keys
42
- )
43
- ),
44
- {}
45
- )
46
- result['UpdateExpression'] = "SET "+", ".join(map(
47
- lambda attrName: f"#{attrName} = :{attrName}",
48
- filter(
49
- lambda attrName: item[attrName] is not None,
50
- keys
51
- )
52
- ))
53
- update_expression_remove = ", ".join(map(
54
- lambda attrName: f"#{attrName}",
55
- filter(
56
- lambda attrName: item[attrName] is None,
57
- keys
58
- )
59
- ))
60
- if update_expression_remove:
61
- result['UpdateExpression'] = "{} REMOVE {}".format(
62
- result['UpdateExpression'],
63
- update_expression_remove
64
- )
65
- result['ReturnValues'] = 'ALL_NEW'
66
- return result
67
-
68
- def timestamp_condition(field_name = 'timestamp'):
69
- return {
70
- 'ConditionExpression': "attribute_not_exists(#{fn}) OR #{fn} < :{fn}".format(
71
- fn=field_name
72
- )
73
- }
74
-
75
- def pk_condition(field_name = 'pk'):
76
- return {
77
- 'ConditionExpression': "attribute_not_exists({})".format(
78
- field_name
79
- )
80
- }
81
-
82
27
  def update_dynamodb(
83
28
  table_name=os.getenv('ENTITY_TABLE_NAME') or os.getenv('EVENT_TABLE_NAME'),
84
29
  update_request_field='update_request',
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modmex-lambda
3
- Version: 0.5.2
3
+ Version: 0.5.4
4
4
  Summary: Ultra-lightweight AWS Lambda utilities for API Gateway routing and event handling.
5
5
  Author: Modmex
6
6
  License: MIT
@@ -36,6 +36,8 @@ API Gateway proxy events, fast routing, request binding, response serialization,
36
36
  middleware, dependency injection, stream sources, parsing, and structured
37
37
  logging.
38
38
 
39
+ The fuller documentation site lives in [`docs/`](docs/).
40
+
39
41
  ## Install
40
42
 
41
43
  ```bash
@@ -979,37 +981,66 @@ materialize so queries stay local and fast, and each service owns the model it
979
981
  needs instead of querying another service synchronously.
980
982
 
981
983
  ```python
984
+ from modmex_lambda.persistence.dynamodb import MaterializedViewMixin
982
985
  from modmex_lambda.stream.flavors.materialize import Materialize
983
986
  from modmex_lambda.stream.rules_registry import RulesRegistry
984
987
  from modmex_lambda.stream.sources import KinesisSource
985
- from modmex_lambda.stream.utils.dynamodb import update_expression
986
988
 
987
989
 
988
- def to_thing_view_update(uow):
989
- thing = uow["event"]["thing"]
990
- return {
991
- "Key": {
992
- "pk": thing["id"],
993
- "sk": "THING",
994
- },
995
- **update_expression({
996
- "name": thing["name"],
997
- "timestamp": uow["event"]["timestamp"],
998
- }),
999
- }
990
+ class ThingViewRepository(MaterializedViewMixin):
991
+ discriminator = "thing"
992
+
993
+
994
+ to_update_request = ThingViewRepository.materialized_update_request_mapper()
1000
995
 
1001
996
 
1002
997
  registry = RulesRegistry().registry(
1003
998
  Materialize({
1004
999
  "id": "materialize-thing",
1005
1000
  "event_type": "thing-created",
1006
- "to_update_request": to_thing_view_update,
1001
+ "to_update_request": to_update_request,
1007
1002
  })
1008
1003
  )
1009
1004
 
1010
1005
  handler = KinesisSource(registry, concurrency=False).handle
1011
1006
  ```
1012
1007
 
1008
+ `MaterializedViewMixin` is a persistence helper for the common DynamoDB case:
1009
+ copy the entity from `uow["event"][discriminator]`, build a `Key` with
1010
+ `pk=entity["id"]` and `sk=discriminator`, add stream-compatible fields, and
1011
+ protect the write with `timestamp_condition()` so older events do not overwrite
1012
+ newer records. It adds stream-compatible fields such as `discriminator`,
1013
+ `timestamp`, `deleted`, `latched`, `ttl`, and `awsregion` to the update
1014
+ expression.
1015
+
1016
+ Override the hooks when the event source, key, projection, or enrichment is
1017
+ different from the default:
1018
+
1019
+ ```python
1020
+ from modmex_lambda.persistence.dynamodb import MaterializedViewMixin
1021
+
1022
+
1023
+ class ThingSearchViewRepository(MaterializedViewMixin):
1024
+ discriminator = "thing-search"
1025
+ materialized_source_name = "thing"
1026
+
1027
+ def materialized_key(self, uow, thing):
1028
+ return {
1029
+ "pk": thing["tenant_id"],
1030
+ "sk": f"thing-search#{thing['id']}",
1031
+ }
1032
+
1033
+ def materialized_fields(self, uow, thing):
1034
+ return {
1035
+ **super().materialized_fields(uow, thing),
1036
+ "search_text": f"{thing['name']} {thing['tenant_id']}",
1037
+ }
1038
+ ```
1039
+
1040
+ Use `DynamoDBUpdateRequestMixin.build_update_request()` directly when you already
1041
+ have the key and fields but still want the standard `UpdateItem` expression and
1042
+ timestamp guard.
1043
+
1013
1044
  Use `split_on` and `split_target_field` when one event updates several records,
1014
1045
  for example one `order-created` event materializing each order item.
1015
1046
 
@@ -13,7 +13,7 @@ modmex_lambda/tracing.py,sha256=WWA2xAqfa23phxoQzRnr6UtdK3S770jPODYh4CwFk0M,5739
13
13
  modmex_lambda/validation.py,sha256=64uWzSnkxzN1t7DAqC_0TYzhupQUtAWnrLATEFAEfzs,7130
14
14
  modmex_lambda/connectors/__init__.py,sha256=-3G8Q5Qo_O6xlD8oWLYW7XCgMln50BTjhrUncAePnUA,716
15
15
  modmex_lambda/connectors/cloudwatch.py,sha256=8NnP6m7IGZwwFZaHw6EO2Q1g4yHUfFqagpkVpzlgW8E,455
16
- modmex_lambda/connectors/dynamodb.py,sha256=uSK7mBKORigcx_BtiLpxob3dBxRTpWkCcdMCgPRclNQ,3575
16
+ modmex_lambda/connectors/dynamodb.py,sha256=QTUtud8pmsmPER2N0foQB9dBg3yCrfEacYk8FfU1XfQ,8821
17
17
  modmex_lambda/connectors/eventbridge.py,sha256=atY6R4XqRL-n6y5-ajn3b-t0gDwaBvz1w5rU4yMmq1U,473
18
18
  modmex_lambda/connectors/icloudwatch.py,sha256=LAxYgMgzda4wtGGRwhsPlZikpGlA3oiUsk73jlJ7ghk,463
19
19
  modmex_lambda/connectors/idynamodb.py,sha256=oZiOztqY59U9O5VpxsAG_b1H2L4VaMH0lWhGGapHfYo,2538
@@ -23,7 +23,7 @@ modmex_lambda/connectors/is3.py,sha256=o3NCwCUqyK7vchSBVusVxhnTjaWaej1TaCmAKiwFA
23
23
  modmex_lambda/connectors/isns.py,sha256=7EgdIUuVC1V0wsdNaZthdepRDZkpw6XBUUzWr6C8NW8,717
24
24
  modmex_lambda/connectors/isqs.py,sha256=qd-MPGuJKeKFJ3wK-RvvPCl_f0x6NC9n8GRsiqI5krQ,706
25
25
  modmex_lambda/connectors/lambda_.py,sha256=kHpQxGSX-5tAtSZlI0VdR3WLP9KCCPIjcnACqTI9vqE,421
26
- modmex_lambda/connectors/module.py,sha256=BTAEK9LBaQ7YIFP6lSzQuoHW3yV4tsQlznIqHj74Iuc,1852
26
+ modmex_lambda/connectors/module.py,sha256=u1wLIt7JF7ncCJA8qX5tTwhJ906u-XnE84B8CzM7yxc,1967
27
27
  modmex_lambda/connectors/s3.py,sha256=sEf4_xV-kQXkOTIwo_Ez9RJxmJWDzRu0mLe0SLjY8-I,1135
28
28
  modmex_lambda/connectors/sns.py,sha256=1X_5laGHTtyOH5gXqhDWk9YnclHzEac1R76xaijZQ_U,879
29
29
  modmex_lambda/connectors/sqs.py,sha256=LwzO3gkQ6bfE6WMJW1KrhsymUqtdZ94RK1suXL7G-k0,806
@@ -55,6 +55,12 @@ modmex_lambda/event_handler/dependencies/dependency_middleware.py,sha256=c_N7Ugt
55
55
  modmex_lambda/event_handler/dependencies/depends.py,sha256=pJ1rcMoA99vSaQTPxJ0zkYppPDKU0-lm9URc9Gifmdw,7110
56
56
  modmex_lambda/event_handler/dependencies/params.py,sha256=bc208GHiXCfm1V-GLeIMm8-ypZ8P8Q9qUKQkK2Vh1Fg,10085
57
57
  modmex_lambda/event_handler/dependencies/types.py,sha256=5VA8zl-EjQDNKXOrNT7sRv_vNs40Lz8aMP-sf-lf8sk,376
58
+ modmex_lambda/persistence/__init__.py,sha256=oa4FHCvitEkoagJ059jGATj6GSm-maazzf_QNi4WdQs,74
59
+ modmex_lambda/persistence/dynamodb/__init__.py,sha256=f_R3NHWfHvTWkCYfBHqY-z75r7pESsUv5FmFf05Glgg,1026
60
+ modmex_lambda/persistence/dynamodb/expressions.py,sha256=E_FDebLgROv63DBRQXKuO1hXjTt9g1kGzbgH2YBNu-Y,1694
61
+ modmex_lambda/persistence/dynamodb/keys.py,sha256=_vjpHYzRmi1OmCzHaJNpq8vefjH7kq8yxE1t7RhmmQg,4676
62
+ modmex_lambda/persistence/dynamodb/materialized_views.py,sha256=CnSWZMYkEHGEpTnWkurB2uxxTN_tPrGFgRthF_0ILng,2892
63
+ modmex_lambda/persistence/dynamodb/stream_fields.py,sha256=b6wRRq6Ag9W59GCI8616IlBQXeBIwj5RKEQE0lfD4YE,3323
58
64
  modmex_lambda/shared/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
59
65
  modmex_lambda/shared/cookies.py,sha256=E2hTuht5_Xljw9zOQLW6vjsOWEpQSIqD2D3Tl1Co_s0,2076
60
66
  modmex_lambda/shared/headers_serializer.py,sha256=Ao-wpx0cmy9E2ACrh7R7RIDUF0h24gax1AMtY_MjIWQ,2505
@@ -113,7 +119,7 @@ modmex_lambda/stream/utils/cloudwatch.py,sha256=B9y-Ab5EaZeso5ph3RxRaK8tU-mQyEvA
113
119
  modmex_lambda/stream/utils/concurrency.py,sha256=7fHyQF4SkOkcao8XmG6UOJQersJhf0n5kauk9Yr_-F0,1336
114
120
  modmex_lambda/stream/utils/contracts.py,sha256=SpGX4JGukHmeb1y2ChahuNUrHtHA_rA61VXz_13VvbU,931
115
121
  modmex_lambda/stream/utils/decorators.py,sha256=-Z1ebvN2m07-lfiEkgRPTEZDL9OI6GSI3w7LWq2Xn88,163
116
- modmex_lambda/stream/utils/dynamodb.py,sha256=PeY8jXutQmQXKLey6TBGFsnua18vZm2-NVFG7UhblqI,4233
122
+ modmex_lambda/stream/utils/dynamodb.py,sha256=vi9OEU9DJDzxFdwQkObk-kuZrT0Z1DFOmMrs4eUoXZ8,2670
117
123
  modmex_lambda/stream/utils/eventbridge.py,sha256=_3Oe6eHFdBBuE-9XowGjPbXxQb60N3v-47ieBWp7tv0,745
118
124
  modmex_lambda/stream/utils/faults.py,sha256=iXIzzSOHIW-kW4J2PKW_v1KviFpao2btvSrnRUer-d4,2660
119
125
  modmex_lambda/stream/utils/filters.py,sha256=8OmVfHt_xmczgZYyq6OkwEByMg5kQKioPQKA0le1CbY,433
@@ -132,7 +138,7 @@ modmex_lambda/stream/utils/time.py,sha256=dnsL2xeWpa1FXm2p8y9rWXtICMBlCBkgLaJ_en
132
138
  modmex_lambda/stream/utils/uow.py,sha256=8WSPVE7AiHInkFDfBEeKOvdirY6GAgBoS7qkF1f1JVM,3207
133
139
  modmex_lambda/stream/utils/data_classes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
134
140
  modmex_lambda/stream/utils/data_classes/dynamodb.py,sha256=h_7MtUyRkvvV1r2QECrRDu7RR4Hh94SwSjc_8K2_XGM,363
135
- modmex_lambda-0.5.2.dist-info/METADATA,sha256=s5iPsfZFyFDYMZLUn7LpP5py9ePfax9KcHevPN5AyFw,31809
136
- modmex_lambda-0.5.2.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
137
- modmex_lambda-0.5.2.dist-info/licenses/LICENSE,sha256=_C2TDTOsYeJvE4vn9VB51laKvleBKbdNnn96wJVtXhQ,1063
138
- modmex_lambda-0.5.2.dist-info/RECORD,,
141
+ modmex_lambda-0.5.4.dist-info/METADATA,sha256=Vt6fiHLcaMqIv2D8dAYERIo2891Az0v_GFLDN_Q0Y5k,33046
142
+ modmex_lambda-0.5.4.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
143
+ modmex_lambda-0.5.4.dist-info/licenses/LICENSE,sha256=_C2TDTOsYeJvE4vn9VB51laKvleBKbdNnn96wJVtXhQ,1063
144
+ modmex_lambda-0.5.4.dist-info/RECORD,,