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
@@ -0,0 +1,157 @@
1
+ __all__ = ['Condition']
2
+
3
+ from typing import Dict, Any, Literal, Optional
4
+
5
+ from boto3.dynamodb.conditions import ComparisonCondition, Attr, ConditionExpressionBuilder
6
+
7
+ import nuql
8
+ from nuql import resources, types
9
+ from . import condition_builder
10
+
11
+
12
+ class Condition:
13
+ def __init__(
14
+ self,
15
+ table: 'resources.Table',
16
+ condition: Dict[str, Any] | None = None,
17
+ condition_type: Literal['FilterExpression', 'ConditionExpression'] = 'FilterExpression',
18
+ ) -> None:
19
+ """
20
+ Base condition builder helper to resolve queries.
21
+
22
+ :arg table: Table instance.
23
+ :param condition: Condition dict.
24
+ :param condition_type: Condition type (FilterExpression or ConditionExpression).
25
+ """
26
+
27
+ self.table = table
28
+ self.variables = condition['variables'] if condition and condition.get('variables') else {}
29
+ self.type = condition_type
30
+ self.condition = None
31
+ self.validator = resources.Validator()
32
+
33
+ if condition:
34
+ query = condition_builder.build_query(condition['where'])
35
+ self.condition = self.resolve(query['condition'])
36
+
37
+ @property
38
+ def resource_args(self) -> Dict[str, Any]:
39
+ """Boto3 resource args for the condition."""
40
+ args = {}
41
+ if self.condition:
42
+ args[self.type] = self.condition
43
+ return args
44
+
45
+ @property
46
+ def client_args(self) -> Dict[str, Any]:
47
+ """Boto3 client args for the condition."""
48
+ if not self.condition:
49
+ return {}
50
+
51
+ builder = ConditionExpressionBuilder()
52
+ expression = builder.build_expression(self.condition, is_key_condition=False)
53
+
54
+ expression_string = getattr(expression, 'condition_expression')
55
+ attribute_name_placeholders = getattr(expression, 'attribute_name_placeholders')
56
+ attribute_value_placeholders = getattr(expression, 'attribute_value_placeholders')
57
+
58
+ return {
59
+ 'ConditionExpression': expression_string,
60
+ 'ExpressionAttributeNames': attribute_name_placeholders,
61
+ 'ExpressionAttributeValues': attribute_value_placeholders,
62
+ }
63
+
64
+ def append(self, condition: str) -> None:
65
+ """
66
+ Append a condition to the current condition.
67
+
68
+ :arg condition: Condition string.
69
+ """
70
+ if isinstance(condition, str):
71
+ condition = condition_builder.build_query(condition)['condition']
72
+ condition = self.resolve(condition)
73
+ if self.condition:
74
+ self.condition &= condition
75
+ else:
76
+ self.condition = condition
77
+
78
+ def resolve(self, part: Any) -> ComparisonCondition:
79
+ """
80
+ Recursively resolves condition parts.
81
+
82
+ :arg part: Part to resolve.
83
+ :return: ComparisonCondition instance.
84
+ """
85
+ # Direct condition/function handling
86
+ if isinstance(part, dict) and part['type'] in ['condition', 'function']:
87
+ attr = Attr(part['field'])
88
+ field_name = part['field']
89
+ field = self.table.fields.get(field_name)
90
+
91
+ is_key = field_name in self.table.indexes.index_keys
92
+ is_projected_to_key = any([x in self.table.indexes.index_keys for x in field.projected_from])
93
+
94
+ # Validate that keys cannot be present in the query
95
+ if (is_key or is_projected_to_key) and self.type == 'FilterExpression':
96
+ raise nuql.NuqlError(
97
+ code='ConditionError',
98
+ message=f'Field \'{field_name}\' cannot be used in a condition query'
99
+ )
100
+
101
+ # Functions are called differently
102
+ if part['type'] == 'function':
103
+ expression = getattr(attr, part['function'])()
104
+
105
+ # Handle basic conditions
106
+ else:
107
+ if not field:
108
+ raise nuql.NuqlError(
109
+ code='ConditionError',
110
+ message=f'Field \'{field_name}\' is not defined in the schema'
111
+ )
112
+
113
+ # Variables provided outside of the query string
114
+ if part['value_type'] == 'variable':
115
+ if part['variable'] not in self.variables:
116
+ raise nuql.NuqlError(
117
+ code='ConditionError',
118
+ message=f'Variable \'{part["variable"]}\' is not defined in the condition'
119
+ )
120
+ value = self.variables[part['variable']]
121
+
122
+ # Rudimentary values passed in the query string
123
+ else:
124
+ value = part['value']
125
+
126
+ # Value is serialised for a query
127
+ expression = getattr(attr, part['operand'])(field(value, action='query', validator=self.validator))
128
+
129
+ return expression
130
+
131
+ # Handle grouped conditions
132
+ elif isinstance(part, dict) and part['type'] == 'parentheses':
133
+ condition = None
134
+ last_operator = None
135
+
136
+ for item in part['conditions']:
137
+ # Logical operator is stored outside of the loop so that it is used
138
+ if isinstance(item, dict) and item['type'] == 'logical_operator':
139
+ last_operator = item['operator']
140
+
141
+ else:
142
+ expression = self.resolve(item)
143
+
144
+ if last_operator is None:
145
+ condition = expression
146
+ elif last_operator == 'and':
147
+ condition &= expression
148
+ elif last_operator == 'or':
149
+ condition |= expression
150
+
151
+ return condition
152
+
153
+ raise nuql.NuqlError(
154
+ code='ConditionParsingError',
155
+ message='Unresolvable condition part was provided',
156
+ part=part
157
+ )
@@ -0,0 +1,211 @@
1
+ __all__ = ['build_query']
2
+
3
+ from typing import Dict, Any, List
4
+
5
+ from pyparsing import Word, alphas, alphanums, Regex, Group, Forward, oneOf, infixNotation, opAssoc, QuotedString, \
6
+ pyparsing_common, ParseException
7
+
8
+ import nuql
9
+
10
+
11
+ ATTR_OPERATORS = {
12
+ # Equals
13
+ 'equals': 'eq',
14
+ '=': 'eq',
15
+ '==': 'eq',
16
+ 'eq': 'eq',
17
+ 'is': 'eq',
18
+
19
+ # Not equals
20
+ 'not_equals': 'ne',
21
+ '!=': 'ne',
22
+ '<>': 'ne',
23
+ 'ne': 'ne',
24
+ 'is_not': 'ne',
25
+
26
+ # Less than
27
+ 'less_than': 'lt',
28
+ '<': 'lt',
29
+ 'lt': 'lt',
30
+
31
+ # Less than or equal to
32
+ 'less_than_or_equals': 'lte',
33
+ 'less_than_equals': 'lte',
34
+ '<=': 'lte',
35
+ 'lte': 'lte',
36
+
37
+ # Greater than
38
+ 'greater_than': 'gt',
39
+ '>': 'gt',
40
+ 'gt': 'gt',
41
+
42
+ # Greater than or equal to
43
+ 'greater_than_or_equals': 'gte',
44
+ 'greater_than_equals': 'gte',
45
+ '>=': 'gte',
46
+ 'gte': 'gte',
47
+
48
+ # Special
49
+ 'begins_with': 'begins_with',
50
+ 'between': 'between',
51
+ 'attribute_type': 'attribute_type',
52
+ 'contains': 'contains',
53
+ 'match': 'contains',
54
+ 'attribute_exists': 'exists',
55
+ 'attribute_not_exists': 'not_exists',
56
+ 'is_in': 'is_in',
57
+ 'in': 'is_in',
58
+ }
59
+
60
+
61
+ field = Word(alphas + '_' + alphanums + '.').set_results_name('field')
62
+
63
+ # Operands and functions
64
+ operand = oneOf('''
65
+ = == equals eq is
66
+ != <> not_equals ne is_not
67
+ < less_than lt
68
+ <= less_than_equals less_than_or_equals lte
69
+ > greater_than gt
70
+ >= greater_than_equals greater_than_or_equals gte
71
+ contains match
72
+ begins_with
73
+ between
74
+ is_in in
75
+ ''', caseless=True).set_results_name('operand')
76
+ func = oneOf('attribute_exists attribute_not_exists', caseless=True).set_results_name('func')
77
+
78
+ # Value parsing
79
+ variable = Regex(r'\$\{[^}]*\}').set_results_name('variable')
80
+ quoted_string = (
81
+ QuotedString('"', unquote_results=False).set_results_name('string') |
82
+ QuotedString("'", unquote_results=False).set_results_name('string')
83
+ )
84
+ integer = pyparsing_common.integer.set_results_name('integer')
85
+ number = pyparsing_common.number.set_results_name('number')
86
+ boolean = oneOf('true false', caseless=True)
87
+ value = variable | quoted_string | number | integer | boolean
88
+
89
+ # Equation groups
90
+ condition_group = Group(field + operand + value)
91
+ function_group = Group(func + '(' + field + ')')
92
+
93
+ expression = Forward()
94
+ expression <<= infixNotation(
95
+ (condition_group | function_group), [
96
+ (oneOf('and', caseless=True), 2, opAssoc.LEFT),
97
+ (oneOf('or', caseless=True), 2, opAssoc.LEFT),
98
+ ]
99
+ )
100
+
101
+
102
+ def build_query(query: str | None) -> Dict[str, Any]:
103
+ """
104
+ Build query dict from string.
105
+
106
+ :arg query: Query string.
107
+ :return: Query dict payload.
108
+ """
109
+ try:
110
+ parsed = expression.parse_string(query)[0].as_list()
111
+ except ParseException as e:
112
+ raise nuql.NuqlError(
113
+ code='ConditionParsingError',
114
+ message=f'Unable to parse condition: {e}',
115
+ )
116
+
117
+ variables = []
118
+
119
+ def recursive_unpack(part: Any, captured_variables: List[str]) -> Dict[str, Any]:
120
+ """
121
+ Recursively unpacks condition parts.
122
+
123
+ :arg part: Condition part.
124
+ :arg captured_variables: List of variables captured in query.
125
+ :return: Dict representation of condition.
126
+ """
127
+ if is_condition(part):
128
+ value_is_variable = isinstance(part[2], str) and part[2].startswith('${') and part[2].endswith('}')
129
+ if value_is_variable:
130
+ captured_variables.append(part[2].replace('${', '').replace('}', ''))
131
+ return {
132
+ 'type': 'condition',
133
+ 'field': part[0],
134
+ 'operand': ATTR_OPERATORS[part[1].lower()],
135
+ **parse_value(part[2])
136
+ }
137
+
138
+ elif is_function(part):
139
+ return {
140
+ 'type': 'function',
141
+ 'field': part[2],
142
+ 'function': ATTR_OPERATORS[part[0].lower()]
143
+ }
144
+
145
+ elif is_logical_operator(part):
146
+ return {'type': 'logical_operator', 'operator': part}
147
+
148
+ else:
149
+ return {
150
+ 'type': 'parentheses',
151
+ 'conditions': [recursive_unpack(item, captured_variables) for item in part]
152
+ }
153
+
154
+ result = recursive_unpack(parsed, variables)
155
+
156
+ return {'condition': result, 'variables': list(set(variables))}
157
+
158
+
159
+ def is_condition(part: Any):
160
+ return isinstance(part, list) and all(isinstance(item, str) for item in part[:-1]) and len(part) == 3
161
+
162
+
163
+ def is_function(part: Any):
164
+ return isinstance(part, list) and all(isinstance(item, str) for item in part) and len(part) == 4
165
+
166
+
167
+ def is_logical_operator(part: Any):
168
+ return isinstance(part, str) and part in ['and', 'AND', 'or', 'OR']
169
+
170
+
171
+ def parse_value(var: str) -> Dict[str, Any]:
172
+ """
173
+ Parses provided variable.
174
+
175
+ :arg var: Variable to parse.
176
+ :return: Condition result keys.
177
+ """
178
+ # Parse variable
179
+ try:
180
+ var = variable.parse_string(var)
181
+ return {'value_type': 'variable', 'variable': var.variable.replace('${', '').replace('}', '')}
182
+ except (ParseException, AttributeError):
183
+ pass
184
+
185
+ # Parse quoted string
186
+ try:
187
+ var = quoted_string.parse_string(var)
188
+ return {
189
+ 'value_type': 'string',
190
+ 'value': var.string.lstrip('"').rstrip('"')
191
+ if var.string.startswith('"')
192
+ else var.string.lstrip("'").rstrip("'")
193
+ }
194
+ except (ParseException, AttributeError):
195
+ pass
196
+
197
+ # Parse integer type
198
+ if isinstance(var, int):
199
+ return {'value_type': 'integer', 'value': var}
200
+
201
+ # Parse number
202
+ if isinstance(var, float):
203
+ return {'value_type': 'number', 'value': var}
204
+
205
+ if isinstance(var, str) and var.lower() == 'true':
206
+ return {'value_type': 'boolean', 'value': True}
207
+
208
+ if isinstance(var, str) and var.lower() == 'false':
209
+ return {'value_type': 'boolean', 'value': False}
210
+
211
+ raise nuql.NuqlError(code='ConditionParsingError', message=f'Unable to parse variable \'{var}\' in condition.')
@@ -0,0 +1,200 @@
1
+ __all__ = ['KeyCondition']
2
+
3
+ from typing import Dict, Any
4
+
5
+ from boto3.dynamodb.conditions import Key, ConditionExpressionBuilder
6
+
7
+ import nuql
8
+ from nuql import resources
9
+
10
+
11
+ KEY_OPERANDS = {
12
+ # Equals
13
+ 'equals': 'eq',
14
+ '=': 'eq',
15
+ '==': 'eq',
16
+ 'eq': 'eq',
17
+
18
+ # Less than
19
+ 'less_than': 'lt',
20
+ '<': 'lt',
21
+ 'lt': 'lt',
22
+
23
+ # Less than or equal to
24
+ 'less_than_or_equals': 'lte',
25
+ 'less_than_equals': 'lte',
26
+ '<=': 'lte',
27
+ 'lte': 'lte',
28
+
29
+ # Greater than
30
+ 'greater_than': 'gt',
31
+ '>': 'gt',
32
+ 'gt': 'gt',
33
+
34
+ # Greater than or equal to
35
+ 'greater_than_or_equals': 'gte',
36
+ 'greater_than_equals': 'gte',
37
+ '>=': 'gte',
38
+ 'gte': 'gte',
39
+
40
+ # Special
41
+ 'begins_with': 'begins_with',
42
+ 'between': 'between',
43
+ }
44
+
45
+
46
+ class KeyCondition:
47
+ def __init__(self, table: 'resources.Table', condition: Dict[str, Any] | None = None, index_name: str = 'primary'):
48
+ """
49
+ DynamoDB key condition builder.
50
+
51
+ :arg table: Table instance.
52
+ :param condition: Condition dict.
53
+ :param index_name: Optional index name.
54
+ """
55
+ if not isinstance(condition, (dict, type(None))):
56
+ raise nuql.NuqlError(code='KeyConditionError', message='Key condition must be a dict or None.')
57
+
58
+ if not condition:
59
+ condition = {}
60
+
61
+ self.index_name = index_name
62
+
63
+ if index_name == 'primary':
64
+ self.index = table.indexes.primary
65
+ else:
66
+ self.index = table.indexes.get_index(index_name)
67
+
68
+ if self.index['hash'] not in condition:
69
+ condition[self.index['hash']] = None
70
+
71
+ parsed_conditions = {}
72
+
73
+ for key, value in condition.items():
74
+ # Validate that the key exists
75
+ if key not in table.fields:
76
+ raise nuql.NuqlError(code='KeyConditionError', message=f'Field \'{key}\' is not defined in the schema.')
77
+
78
+ field = table.fields[key]
79
+
80
+ is_hash_key = key == self.index['hash']
81
+ is_sort_key = key == self.index.get('sort')
82
+ projects_to_hash = self.index['hash'] in field.projected_from
83
+ projects_to_sort = self.index.get('sort') in field.projected_from
84
+
85
+ # Validate that the key is available on the index
86
+ if not any([is_hash_key, is_sort_key, projects_to_hash, projects_to_sort]):
87
+ raise nuql.NuqlError(
88
+ code='KeyConditionError',
89
+ message=f'Field \'{key}\' cannot be used in a key condition on \'{index_name}\' index '
90
+ f'as it is not a hash/sort key and it doesn\'t project to the hash/sort key.'
91
+ )
92
+
93
+ operand, condition_value = self.extract_condition(key, value)
94
+
95
+ # Directly set the key field where not projected
96
+ if is_hash_key or is_sort_key:
97
+ parsed_conditions[key] = [operand, condition_value]
98
+
99
+ # Process projected field
100
+ else:
101
+ key_name = self.index['hash'] if is_hash_key else self.index['sort']
102
+
103
+ if key_name not in parsed_conditions:
104
+ parsed_conditions[key_name] = ['eq', {}]
105
+
106
+ if parsed_conditions[key_name][0] != 'eq' and operand != 'eq':
107
+ raise nuql.NuqlError(
108
+ code='KeyConditionError',
109
+ message=f'Multiple non-equals operators provided for the key \'{key_name}\' '
110
+ 'will result in an ambiguous key condition.'
111
+ )
112
+
113
+ if operand != 'eq':
114
+ parsed_conditions[key_name][0] = operand
115
+
116
+ parsed_conditions[key_name][1][key] = condition_value
117
+
118
+ self.condition = None
119
+ validator = resources.Validator()
120
+
121
+ # Generate key condition
122
+ for key, (operand, value) in parsed_conditions.items():
123
+ field = table.fields[key]
124
+ key_obj = Key(key)
125
+
126
+ if operand == 'between':
127
+ if len(value) != 2:
128
+ raise nuql.NuqlError(
129
+ code='KeyConditionError',
130
+ message=f'Between operator requires exactly two values for the key \'{key}\'.'
131
+ )
132
+ serialised_value = (field(value[0], 'query', validator), field(value[1], 'query', validator))
133
+ key_condition = getattr(key_obj, operand)(*serialised_value)
134
+ else:
135
+ serialised_value = field(value, 'query', validator)
136
+ key_condition = getattr(key_obj, operand)(serialised_value)
137
+
138
+ if self.condition is None:
139
+ self.condition = key_condition
140
+ else:
141
+ self.condition &= key_condition
142
+
143
+ @property
144
+ def resource_args(self) -> Dict[str, Any]:
145
+ """Query request args."""
146
+ if self.condition is None:
147
+ raise nuql.NuqlError(code='KeyConditionError', message='Key condition is empty.')
148
+
149
+ args: Dict[str, Any] = {'KeyConditionExpression': self.condition}
150
+ if self.index_name != 'primary':
151
+ args['IndexName'] = self.index_name
152
+
153
+ return args
154
+
155
+ @property
156
+ def client_args(self) -> Dict[str, Any]:
157
+ """Boto3 client args for the condition."""
158
+ if self.condition is None:
159
+ raise nuql.NuqlError(code='KeyConditionError', message='Key condition is empty.')
160
+
161
+ args = {}
162
+
163
+ if self.index_name != 'primary':
164
+ args['IndexName'] = self.index_name
165
+
166
+ builder = ConditionExpressionBuilder()
167
+ expression = builder.build_expression(self.condition, is_key_condition=True)
168
+
169
+ args['KeyConditionExpression'] = getattr(expression, 'condition_expression')
170
+ args['ExpressionAttributeNames'] = getattr(expression, 'attribute_name_placeholders')
171
+ args['ExpressionAttributeValues'] = getattr(expression, 'attribute_value_placeholders')
172
+
173
+ return args
174
+
175
+ @staticmethod
176
+ def extract_condition(key: str, value: Any) -> (str, Any):
177
+ """
178
+ Parses and extracts the operand and value from the condition.
179
+
180
+ :arg key: Condition key.
181
+ :arg value: Condition value or dict.
182
+ :return: Tuple containing the operand and value.
183
+ """
184
+ value_keys = list(value.keys()) if isinstance(value, dict) else []
185
+ if isinstance(value, dict) and all([x.lower() in KEY_OPERANDS for x in value.keys()]):
186
+ if len(value_keys) > 1:
187
+ raise nuql.NuqlError(
188
+ code='KeyConditionError',
189
+ message=f'Multiple operators provided for the key \'{key}\' (' + ', '.join(value_keys) + ').'
190
+ )
191
+
192
+ condition_dict = next(iter(value.items()))
193
+
194
+ operand = KEY_OPERANDS[condition_dict[0].lower()]
195
+ condition_value = condition_dict[1]
196
+ else:
197
+ operand = 'eq'
198
+ condition_value = value
199
+
200
+ return operand, condition_value