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.
Files changed (65) hide show
  1. nuql/__init__.py +3 -0
  2. nuql/api/__init__.py +13 -0
  3. nuql/api/adapter.py +34 -0
  4. nuql/api/batch_get/__init__.py +2 -0
  5. nuql/api/batch_get/batch_get.py +40 -0
  6. nuql/api/batch_get/queue.py +120 -0
  7. nuql/api/batch_write.py +99 -0
  8. nuql/api/condition_check.py +39 -0
  9. nuql/api/create.py +25 -0
  10. nuql/api/delete.py +88 -0
  11. nuql/api/get.py +30 -0
  12. nuql/api/put_item.py +112 -0
  13. nuql/api/put_update.py +25 -0
  14. nuql/api/query/__init__.py +4 -0
  15. nuql/api/query/condition.py +157 -0
  16. nuql/api/query/condition_builder.py +211 -0
  17. nuql/api/query/key_condition.py +200 -0
  18. nuql/api/query/query.py +166 -0
  19. nuql/api/transaction.py +145 -0
  20. nuql/api/update/__init__.py +3 -0
  21. nuql/api/update/expression_builder.py +33 -0
  22. nuql/api/update/update_item.py +139 -0
  23. nuql/api/update/utils.py +126 -0
  24. nuql/api/upsert.py +32 -0
  25. nuql/client.py +88 -0
  26. nuql/connection.py +43 -0
  27. nuql/exceptions.py +66 -0
  28. nuql/fields/__init__.py +11 -0
  29. nuql/fields/boolean.py +29 -0
  30. nuql/fields/datetime.py +49 -0
  31. nuql/fields/datetime_timestamp.py +45 -0
  32. nuql/fields/float.py +40 -0
  33. nuql/fields/integer.py +40 -0
  34. nuql/fields/key.py +207 -0
  35. nuql/fields/list.py +90 -0
  36. nuql/fields/map.py +67 -0
  37. nuql/fields/string.py +184 -0
  38. nuql/fields/ulid.py +39 -0
  39. nuql/fields/uuid.py +42 -0
  40. nuql/generators/__init__.py +3 -0
  41. nuql/generators/datetime.py +37 -0
  42. nuql/generators/ulid.py +10 -0
  43. nuql/generators/uuid.py +19 -0
  44. nuql/resources/__init__.py +4 -0
  45. nuql/resources/fields/__init__.py +3 -0
  46. nuql/resources/fields/field.py +153 -0
  47. nuql/resources/fields/field_map.py +85 -0
  48. nuql/resources/fields/value.py +5 -0
  49. nuql/resources/records/__init__.py +3 -0
  50. nuql/resources/records/projections.py +49 -0
  51. nuql/resources/records/serialiser.py +144 -0
  52. nuql/resources/records/validator.py +48 -0
  53. nuql/resources/tables/__init__.py +2 -0
  54. nuql/resources/tables/indexes.py +140 -0
  55. nuql/resources/tables/table.py +151 -0
  56. nuql/resources/utils/__init__.py +2 -0
  57. nuql/resources/utils/dict.py +21 -0
  58. nuql/resources/utils/validators.py +165 -0
  59. nuql/types/__init__.py +3 -0
  60. nuql/types/config.py +27 -0
  61. nuql/types/fields.py +27 -0
  62. nuql/types/serialisation.py +10 -0
  63. nuql-0.0.1.dist-info/METADATA +12 -0
  64. nuql-0.0.1.dist-info/RECORD +65 -0
  65. nuql-0.0.1.dist-info/WHEEL +4 -0
nuql/client.py ADDED
@@ -0,0 +1,88 @@
1
+ __all__ = ['Nuql']
2
+
3
+ from typing import List, Type, Dict, Any
4
+
5
+ from boto3 import Session
6
+
7
+ from nuql import types, api, resources
8
+ from . import Connection, exceptions
9
+
10
+
11
+ class Nuql:
12
+ def __init__(
13
+ self,
14
+ name: str,
15
+ indexes: List[Dict[str, Any]] | Dict[str, Any],
16
+ schema: Dict[str, Any],
17
+ session: Session | None = None,
18
+ custom_fields: List[Type['types.FieldType']] | None = None,
19
+ global_fields: Dict[str, Any] | None = None,
20
+ ) -> None:
21
+ """
22
+ Nuql - a lightweight DynamoDB library for implementing
23
+ the single table model pattern.
24
+
25
+ :arg name: DynamoDB table name.
26
+ :arg indexes: Table index definition.
27
+ :arg schema: Table design.
28
+ :param session: Boto3 Session instance.
29
+ :param custom_fields: List of custom field types.
30
+ :param global_fields: Additional field definitions to apply to all tables.
31
+ """
32
+ if not isinstance(session, Session):
33
+ session = Session()
34
+
35
+ if custom_fields is None:
36
+ custom_fields = []
37
+
38
+ # Insert global fields on to all tables
39
+ if isinstance(global_fields, dict):
40
+ for table in schema.values():
41
+ table.update(global_fields)
42
+
43
+ self.connection = Connection(name, session)
44
+ self.fields = custom_fields
45
+ self.__schema = schema
46
+ self.__indexes = resources.Indexes(indexes)
47
+
48
+ resources.validate_schema(self.__schema, self.fields)
49
+
50
+ @property
51
+ def indexes(self) -> 'resources.Indexes':
52
+ return self.__indexes
53
+
54
+ @property
55
+ def schema(self) -> 'types.SchemaConfig':
56
+ return self.__schema
57
+
58
+ def batch_write(self) -> 'api.BatchWrite':
59
+ """
60
+ Instantiates a `BatchWrite` object for performing batch writes to DynamoDB.
61
+
62
+ :return: BatchWrite instance.
63
+ """
64
+ return api.BatchWrite(self)
65
+
66
+ def transaction(self) -> 'api.Transaction':
67
+ """
68
+ Instantiates a `Transaction` object for performing transactions on a DynamoDB table.
69
+
70
+ :return: Transaction instance.
71
+ """
72
+ return api.Transaction(self)
73
+
74
+ def get_table(self, name: str) -> 'resources.Table':
75
+ """
76
+ Instantiates a `Table` object for the chosen table in the schema.
77
+
78
+ :arg name: Table name (in schema) to instantiate.
79
+ :return: Table instance.
80
+ """
81
+ if name not in self.__schema:
82
+ raise exceptions.NuqlError(
83
+ code='TableNotDefined',
84
+ message=f'Table \'{name}\' is not defined in the schema.'
85
+ )
86
+
87
+ schema = self.__schema[name]
88
+ return resources.Table(name=name, provider=self, schema=schema, indexes=self.indexes)
nuql/connection.py ADDED
@@ -0,0 +1,43 @@
1
+ __all__ = ['Connection']
2
+
3
+ from boto3 import Session
4
+
5
+
6
+ class Connection:
7
+ def __init__(self, table_name: str, boto3_session: Session, **connection_args) -> None:
8
+ """
9
+ Wrapper for the `boto3` DynamoDB resources/clients to be
10
+ shared throughout the library.
11
+
12
+ :arg table_name: DynamoDB table name.
13
+ :arg boto3_session: Session instance.
14
+ :param connection_args: Additional args to pass to the client/resource.
15
+ """
16
+ self.table_name = table_name
17
+ self.session = boto3_session
18
+ self.__connection_args = connection_args
19
+
20
+ self.__resource = None
21
+ self.__client = None
22
+ self.__table = None
23
+
24
+ @property
25
+ def resource(self):
26
+ """Creates a `boto3` DynamoDB resource if it doesn't exist."""
27
+ if self.__resource is None:
28
+ self.__resource = self.session.resource('dynamodb', **self.__connection_args)
29
+ return self.__resource
30
+
31
+ @property
32
+ def client(self):
33
+ """Creates a `boto3` DynamoDB client if it doesn't exist."""
34
+ if self.__client is None:
35
+ self.__client = self.session.client('dynamodb', **self.__connection_args)
36
+ return self.__client
37
+
38
+ @property
39
+ def table(self):
40
+ """Creates a `boto3` DynamoDB table if it doesn't exist."""
41
+ if self.__table is None:
42
+ self.__table = self.resource.Table(self.table_name)
43
+ return self.__table
nuql/exceptions.py ADDED
@@ -0,0 +1,66 @@
1
+ __all__ = ['NuqlError', 'ValidationError', 'Boto3Error', 'ItemNotFound']
2
+
3
+ from typing import List, Dict, Any
4
+
5
+ from botocore.exceptions import ClientError
6
+
7
+ from nuql import types
8
+
9
+
10
+ class NuqlError(Exception):
11
+ def __init__(self, code: str, message: str, **details) -> None:
12
+ """
13
+ Base exception for Nuql.
14
+
15
+ :arg code: Error code.
16
+ :arg message: Error message.
17
+ :param details: Arbitrary details to add to the exception.
18
+ """
19
+ self.code = code
20
+ self.message = message
21
+ self.details = details
22
+
23
+ super().__init__(f'[{self.code}] {self.message}')
24
+
25
+
26
+ class ValidationError(NuqlError):
27
+ def __init__(self, errors: List['types.ValidationErrorItem']):
28
+ """
29
+ Exception for validation errors during the serialisation process.
30
+
31
+ :arg errors: List of ValidationErrorItem dicts.
32
+ """
33
+ self.errors = errors
34
+
35
+ formatted_message = 'Schema validation errors occurred:\n\n'
36
+
37
+ for error in self.errors:
38
+ formatted_message += f' \'{error["name"]}\': {error["message"]}\n'
39
+
40
+ super().__init__('ValidationError', formatted_message)
41
+
42
+
43
+ class Boto3Error(NuqlError):
44
+ def __init__(self, exc: ClientError, request_args: Dict[str, Any]):
45
+ """
46
+ Exception wrapper for boto3 ClientError.
47
+
48
+ :arg exc: ClientError instance.
49
+ """
50
+ self.request_args = request_args
51
+ self.error_info = exc.response.get('Error', {})
52
+ self.code = self.error_info.get('Code', 'UnknownError')
53
+ self.message = self.error_info.get('Message', str(exc))
54
+
55
+ super().__init__(code=self.code, message=self.message)
56
+
57
+
58
+ class ItemNotFound(NuqlError):
59
+ def __init__(self, key: Dict[str, Any]) -> None:
60
+ """
61
+ Thrown when an item is not found when doing a get_item request.
62
+
63
+ :arg key: Key used to retrieve the item.
64
+ """
65
+ self.key = key
66
+ super().__init__('ItemNotFound', f'Item not found: {key}')
@@ -0,0 +1,11 @@
1
+ from .string import *
2
+ from .key import *
3
+ from .boolean import *
4
+ from .datetime import *
5
+ from .datetime_timestamp import *
6
+ from .list import *
7
+ from .map import *
8
+ from .integer import *
9
+ from .float import *
10
+ from .uuid import *
11
+ from .ulid import *
nuql/fields/boolean.py ADDED
@@ -0,0 +1,29 @@
1
+ __all__ = ['Boolean']
2
+
3
+ from nuql.resources import FieldBase
4
+
5
+
6
+ class Boolean(FieldBase):
7
+ type = 'boolean'
8
+
9
+ def serialise(self, value: bool | None) -> bool | None:
10
+ """
11
+ Serialises a boolean value (type checker).
12
+
13
+ :arg value: Boolean value.
14
+ :return: Boolean value.
15
+ """
16
+ if isinstance(value, bool):
17
+ return value
18
+ return None
19
+
20
+ def deserialise(self, value: bool | None) -> bool | None:
21
+ """
22
+ Deserialises a boolean value (type checker).
23
+
24
+ :arg value: Boolean value.
25
+ :return: Boolean value.
26
+ """
27
+ if isinstance(value, bool):
28
+ return value
29
+ return None
@@ -0,0 +1,49 @@
1
+ __all__ = ['Datetime']
2
+
3
+ from datetime import datetime, UTC
4
+
5
+ import nuql
6
+ from nuql.resources import FieldBase
7
+
8
+
9
+ class Datetime(FieldBase):
10
+ type = 'datetime'
11
+ date_format = '%Y-%m-%dT%H:%M:%S%z'
12
+
13
+ def serialise(self, value: datetime | None) -> str | None:
14
+ """
15
+ Serialises a datetime value.
16
+
17
+ :arg value: datetime instance or None.
18
+ :return: String representation of the datetime instance.
19
+ """
20
+ if not isinstance(value, datetime):
21
+ return None
22
+
23
+ # Validate that the datetime is timezone-aware
24
+ if not value.tzinfo:
25
+ raise nuql.NuqlError(
26
+ code='SerialisationError',
27
+ message='Datetime value must be timezone-aware.'
28
+ )
29
+
30
+ return str(value.astimezone(UTC).strftime(self.date_format))
31
+
32
+ def deserialise(self, value: str | None) -> datetime | None:
33
+ """
34
+ Deserialises a datetime value.
35
+
36
+ :arg value: String representation of the datetime.
37
+ :return: datetime instance or None.
38
+ """
39
+ if value is None:
40
+ return None
41
+
42
+ # Parse the datetime string
43
+ try:
44
+ dt = datetime.strptime(value, self.date_format)
45
+ dt = dt.replace(tzinfo=UTC)
46
+ return dt
47
+
48
+ except (ValueError, TypeError):
49
+ return None
@@ -0,0 +1,45 @@
1
+ __all__ = ['DatetimeTimestamp']
2
+
3
+ from datetime import datetime, UTC
4
+ from decimal import Decimal
5
+
6
+ import nuql
7
+ from nuql.resources import FieldBase
8
+
9
+
10
+ class DatetimeTimestamp(FieldBase):
11
+ type = 'datetime_timestamp'
12
+
13
+ def serialise(self, value: datetime | None) -> int | None:
14
+ """
15
+ Serialises a datetime to a timestamp.
16
+
17
+ :arg value: datetime instance or None.
18
+ :return: int or None.
19
+ """
20
+ if not isinstance(value, datetime):
21
+ return None
22
+
23
+ # Validate timezone-awareness
24
+ if value.tzinfo is None:
25
+ raise nuql.NuqlError(
26
+ code='SerialisationError',
27
+ message='Datetime value must be timezone-aware.'
28
+ )
29
+
30
+ return int(value.astimezone(UTC).timestamp() * 1000)
31
+
32
+ def deserialise(self, value: Decimal | None) -> datetime | None:
33
+ """
34
+ Deserialises a timestamp to a datetime.
35
+
36
+ :arg value: Decimal instance or None.
37
+ :return: datetime instance or None.
38
+ """
39
+ if not isinstance(value, Decimal):
40
+ return None
41
+
42
+ try:
43
+ return datetime.fromtimestamp(int(value) / 1000, UTC)
44
+ except (ValueError, TypeError):
45
+ return None
nuql/fields/float.py ADDED
@@ -0,0 +1,40 @@
1
+ __all__ = ['Float']
2
+
3
+ from decimal import Decimal
4
+ from typing import Any
5
+
6
+ from nuql import resources, types
7
+ from nuql.api import Incrementor
8
+
9
+
10
+ class Float(resources.FieldBase):
11
+ type = 'float'
12
+
13
+ def serialise(self, value: float | Incrementor | None) -> Decimal | Incrementor | None:
14
+ """
15
+ Serialises a float value.
16
+
17
+ :arg value: Value as float, Incrementor or None.
18
+ :return: Value as Decimal, Incrementor or None.
19
+ """
20
+ if not isinstance(value, (float, Incrementor)):
21
+ return None
22
+ if isinstance(value, Incrementor):
23
+ return value
24
+ return Decimal(str(value))
25
+
26
+ def deserialise(self, value: Decimal | None) -> float | None:
27
+ """
28
+ Deserialises a float value.
29
+
30
+ :arg value: Value as Decimal or None.
31
+ :return: Value as float or None.
32
+ """
33
+ if not isinstance(value, Decimal):
34
+ return None
35
+ return float(value)
36
+
37
+ def internal_validation(self, value: Any, action: 'types.SerialisationType', validator: 'resources.Validator'):
38
+ """Validate the Incrementor type."""
39
+ if isinstance(value, Incrementor) and action != 'update':
40
+ validator.add(name=self.name, message='Incrementors can only be used for updates')
nuql/fields/integer.py ADDED
@@ -0,0 +1,40 @@
1
+ __all__ = ['Integer']
2
+
3
+ from decimal import Decimal
4
+ from typing import Any
5
+
6
+ from nuql.api import Incrementor
7
+ from nuql import resources, types
8
+
9
+
10
+ class Integer(resources.FieldBase):
11
+ type = 'int'
12
+
13
+ def serialise(self, value: int | Incrementor | None) -> Decimal | Incrementor | None:
14
+ """
15
+ Serialises an integer value.
16
+
17
+ :arg value: Value as int, Incrementor or None.
18
+ :return: Value as Decimal, Incrementor or None.
19
+ """
20
+ if not isinstance(value, (int, Incrementor)):
21
+ return None
22
+ if isinstance(value, Incrementor):
23
+ return value
24
+ return Decimal(str(value))
25
+
26
+ def deserialise(self, value: Decimal | None) -> int | None:
27
+ """
28
+ Deserialises an integer value.
29
+
30
+ :arg value: Value as Decimal or None.
31
+ :return: Value as int or None.
32
+ """
33
+ if not isinstance(value, Decimal):
34
+ return None
35
+ return int(value)
36
+
37
+ def internal_validation(self, value: Any, action: 'types.SerialisationType', validator: 'resources.Validator'):
38
+ """Validate the Incrementor type."""
39
+ if isinstance(value, Incrementor) and action != 'update':
40
+ validator.add(name=self.name, message='Incrementors can only be used for updates')
nuql/fields/key.py ADDED
@@ -0,0 +1,207 @@
1
+ __all__ = ['Key']
2
+
3
+ import re
4
+ from typing import Dict, Any
5
+
6
+ import nuql
7
+ from nuql import resources, types
8
+
9
+
10
+ class Key(resources.FieldBase):
11
+ type = 'key'
12
+
13
+ def __call__(self, value: Any, action: 'types.SerialisationType', validator: 'resources.Validator') -> Any:
14
+ """
15
+ Encapsulates the internal serialisation logic to prepare for
16
+ sending the record to DynamoDB.
17
+
18
+ :arg value: Deserialised value.
19
+ :arg action: SerialisationType (`create`, `update`, `write` or `query`).
20
+ :arg validator: Validator instance.
21
+ :return: Serialised value.
22
+ """
23
+ has_value = not isinstance(value, resources.EmptyValue)
24
+
25
+ # Apply generators if applicable to the field to overwrite the value
26
+ if action in ['create', 'update', 'write']:
27
+ if action == 'create' and self.on_create:
28
+ value = self.on_create()
29
+
30
+ if action == 'update' and self.on_update:
31
+ value = self.on_update()
32
+
33
+ if self.on_write:
34
+ value = self.on_write()
35
+
36
+ # Set default value if applicable
37
+ if not has_value and not value:
38
+ value = self.default
39
+
40
+ # Serialise the value
41
+ value = self.serialise_template(value, action, validator)
42
+
43
+ # Validate required field
44
+ if self.required and action == 'create' and value is None:
45
+ validator.add(name=self.name, message='Field is required')
46
+
47
+ # Validate against enum
48
+ if self.enum and has_value and action in ['create', 'update', 'write'] and value not in self.enum:
49
+ validator.add(name=self.name, message=f'Value must be one of: {", ".join(self.enum)}')
50
+
51
+ # Run internal validation
52
+ self.internal_validation(value, action, validator)
53
+
54
+ # Run custom validation logic
55
+ if self.validator and action in ['create', 'update', 'write']:
56
+ self.validator(value, validator)
57
+
58
+ return value
59
+
60
+ def on_init(self) -> None:
61
+ """Initialises the key field."""
62
+ # Validate the field has a value
63
+ if self.value is None:
64
+ raise nuql.NuqlError(
65
+ code='KeySchemaError',
66
+ message='\'value\' must be defined for a key field'
67
+ )
68
+
69
+ # Callback fn handles configuring projected fields on the schema
70
+ def callback(field_map: dict) -> None:
71
+ """Callback fn to configure projected fields on the schema."""
72
+ for key, value in self.value.items():
73
+ projected_name = self.parse_projected_name(value)
74
+
75
+ # Skip fixed value fields
76
+ if not projected_name:
77
+ continue
78
+
79
+ # Validate projected key exists on the table
80
+ if projected_name not in field_map:
81
+ raise nuql.NuqlError(
82
+ code='KeySchemaError',
83
+ message=f'Field \'{projected_name}\' (projected on key '
84
+ f'\'{self.name}\') is not defined in the schema'
85
+ )
86
+
87
+ # Add reference to this field on the projected field
88
+ field_map[projected_name].projected_from.append(self.name)
89
+ self.projects_fields.append(projected_name)
90
+
91
+ if self.init_callback is not None:
92
+ self.init_callback(callback)
93
+
94
+ def serialise_template(
95
+ self,
96
+ key_dict: Dict[str, Any],
97
+ action: 'types.SerialisationType',
98
+ validator: 'resources.Validator'
99
+ ) -> str:
100
+ """
101
+ Serialises the key dict to a string.
102
+
103
+ :arg key_dict: Dict to serialise.
104
+ :arg action: Serialisation type.
105
+ :arg validator: Validator instance.
106
+ :return: Serialised representation.
107
+ """
108
+ output = ''
109
+ s = self.sanitise
110
+
111
+ for key, value in self.value.items():
112
+ projected_name = self.parse_projected_name(value)
113
+
114
+ if projected_name in self.projects_fields:
115
+ projected_field = self.parent.fields.get(projected_name)
116
+
117
+ if projected_field is None:
118
+ raise nuql.NuqlError(
119
+ code='KeySchemaError',
120
+ message=f'Field \'{projected_name}\' (projected on key '
121
+ f'\'{self.name}\') is not defined in the schema'
122
+ )
123
+
124
+ projected_value = key_dict.get(projected_name)
125
+ serialised_value = projected_field(projected_value, action, validator)
126
+ used_value = s(serialised_value) if serialised_value else None
127
+ else:
128
+ used_value = s(value)
129
+
130
+ # A query might provide only a partial value
131
+ if projected_name is not None and projected_name not in value:
132
+ break
133
+
134
+ output += f'{s(key)}:{used_value if used_value else ""}|'
135
+
136
+ return output[:-1]
137
+
138
+ def deserialise(self, value: str) -> Dict[str, Any]:
139
+ """
140
+ Deserialises the key string to a dict.
141
+
142
+ :arg value: String key value.
143
+ :return: Key dict.
144
+ """
145
+ output = {}
146
+
147
+ if value is None:
148
+ return output
149
+
150
+ unmarshalled = {
151
+ key: serialised_value
152
+ if serialised_value else None
153
+ for key, serialised_value in [item.split(':') for item in value.split('|')]
154
+ }
155
+
156
+ for key, serialised_value in self.value.items():
157
+ provided_value = unmarshalled.get(key)
158
+ projected_name = self.parse_projected_name(serialised_value)
159
+
160
+ if projected_name in self.projects_fields:
161
+ projected_field = self.parent.fields.get(projected_name)
162
+
163
+ if projected_field is None:
164
+ raise nuql.NuqlError(
165
+ code='KeySchemaError',
166
+ message=f'Field \'{projected_name}\' (projected on key '
167
+ f'\'{self.name}\') is not defined in the schema'
168
+ )
169
+
170
+ deserialised_value = projected_field.deserialise(provided_value)
171
+ output[projected_name] = deserialised_value
172
+ else:
173
+ output[key] = provided_value
174
+
175
+ return output
176
+
177
+ @staticmethod
178
+ def parse_projected_name(value: str) -> str | None:
179
+ """
180
+ Parses key name in the format '${field_name}'.
181
+
182
+ :arg value: Value to parse.
183
+ :return: Field name if it matches the format.
184
+ """
185
+ if not isinstance(value, str):
186
+ return None
187
+ match = re.search(r'\$\{([a-zA-Z0-9_]+)}', value)
188
+ if not match:
189
+ return None
190
+ else:
191
+ return match.group(1)
192
+
193
+ @staticmethod
194
+ def sanitise(value: str) -> str:
195
+ """
196
+ Sanitises the input to avoid conflict with serialisation/deserialisation.
197
+
198
+ :arg value: String value.
199
+ :return: Sanitised string value.
200
+ """
201
+ if not isinstance(value, str):
202
+ value = str(value)
203
+
204
+ for character in [':', '|']:
205
+ value = value.replace(character, '')
206
+
207
+ return value