nuql 0.0.6__py3-none-any.whl → 0.0.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
nuql/api/batch_write.py CHANGED
@@ -1,6 +1,6 @@
1
1
  __all__ = ['BatchWrite']
2
2
 
3
- from typing import Dict, Any, Optional
3
+ from typing import Dict, Any, Literal
4
4
 
5
5
  from botocore.exceptions import ClientError
6
6
 
@@ -51,35 +51,39 @@ class BatchWrite:
51
51
  message='Batch write context manager has not been started'
52
52
  )
53
53
 
54
- def create(self,
54
+ def put(
55
+ self,
55
56
  table: 'resources.Table',
56
57
  data: Dict[str, Any],
58
+ serialisation_type: Literal['create', 'update', 'write'] = 'write'
57
59
  ) -> None:
58
60
  """
59
61
  Create a new item on the table as part of a batch write.
60
62
 
61
63
  :arg table: Table instance.
62
64
  :arg data: Data to create.
65
+ :param serialisation_type: Data serialisation type.
63
66
  """
64
67
  self._validate_started()
65
- create = api.Create(self.client, table)
66
- args = create.prepare_args(data=data, exclude_condition=True)
67
- self._actions['put_item'].append(args)
68
68
 
69
- def update(
70
- self,
71
- table: 'resources.Table',
72
- data: Dict[str, Any],
73
- ) -> None:
74
- """
75
- Update an existing item on the table as part of a batch write.
69
+ if serialisation_type == 'create':
70
+ create = api.Create(self.client, table)
71
+ args = create.prepare_args(data=data, exclude_condition=True)
72
+
73
+ elif serialisation_type == 'update':
74
+ put_update = api.PutUpdate(self.client, table)
75
+ args = put_update.prepare_args(data=data, exclude_condition=True)
76
+
77
+ elif serialisation_type == 'write':
78
+ put = api.PutItem(self.client, table)
79
+ args = put.prepare_args(data=data, exclude_condition=True)
80
+
81
+ else:
82
+ raise nuql.NuqlError(
83
+ code='BatchWriteError',
84
+ message=f'Invalid serialisation type: {serialisation_type}'
85
+ )
76
86
 
77
- :arg table: Table instance.
78
- :arg data: Data to update.
79
- """
80
- self._validate_started()
81
- put_update = api.PutUpdate(self.client, table)
82
- args = put_update.prepare_args(data=data, exclude_condition=True)
83
87
  self._actions['put_item'].append(args)
84
88
 
85
89
  def delete(
@@ -9,7 +9,7 @@ from nuql.api import Boto3Adapter
9
9
 
10
10
 
11
11
  class ConditionCheck(Boto3Adapter):
12
- def prepare_client_args(self, key: Dict[str, Any], condition: Dict[str, Any], **kwargs) -> Dict[str, Any]:
12
+ def prepare_client_args(self, key: Dict[str, Any], condition: str | Dict[str, Any], **kwargs) -> Dict[str, Any]:
13
13
  """
14
14
  Prepare the request args for a condition check operation against the table (client API).
15
15
 
nuql/api/delete.py CHANGED
@@ -14,7 +14,7 @@ class Delete(Boto3Adapter):
14
14
  def prepare_client_args(
15
15
  self,
16
16
  key: Dict[str, Any],
17
- condition: Dict[str, Any] | None = None,
17
+ condition: str | Dict[str, Any] | None = None,
18
18
  exclude_condition: bool = False,
19
19
  **kwargs,
20
20
  ) -> Dict[str, Any]:
nuql/api/put_item.py CHANGED
@@ -16,7 +16,7 @@ class PutItem(Boto3Adapter):
16
16
  def prepare_client_args(
17
17
  self,
18
18
  data: Dict[str, Any],
19
- condition: Optional[Dict[str, Any]] = None,
19
+ condition: Optional[str | Dict[str, Any]] = None,
20
20
  exclude_condition: bool = False,
21
21
  **kwargs,
22
22
  ) -> Dict[str, Any]:
@@ -59,7 +59,7 @@ class PutItem(Boto3Adapter):
59
59
  def prepare_args(
60
60
  self,
61
61
  data: Dict[str, Any],
62
- condition: Dict[str, Any] | None = None,
62
+ condition: str | Dict[str, Any] | None = None,
63
63
  exclude_condition: bool = False,
64
64
  **kwargs,
65
65
  ) -> Dict[str, Any]:
@@ -94,7 +94,7 @@ class PutItem(Boto3Adapter):
94
94
  """
95
95
  pass
96
96
 
97
- def invoke_sync(self, data: Dict[str, Any], condition: Dict[str, Any] | None = None) -> Dict[str, Any]:
97
+ def invoke_sync(self, data: Dict[str, Any], condition: str | Dict[str, Any] | None = None) -> Dict[str, Any]:
98
98
  """
99
99
  Perform a put operation against the table.
100
100
 
@@ -13,7 +13,7 @@ class Condition:
13
13
  def __init__(
14
14
  self,
15
15
  table: 'resources.Table',
16
- condition: Dict[str, Any] | None = None,
16
+ condition: str | Dict[str, Any] | None = None,
17
17
  condition_type: Literal['FilterExpression', 'ConditionExpression'] = 'FilterExpression',
18
18
  ) -> None:
19
19
  """
@@ -23,6 +23,9 @@ class Condition:
23
23
  :param condition: Condition dict.
24
24
  :param condition_type: Condition type (FilterExpression or ConditionExpression).
25
25
  """
26
+ # Initialise the condition even when it is a string
27
+ if isinstance(condition, str):
28
+ condition = {'where': condition, 'variables': {}}
26
29
 
27
30
  self.table = table
28
31
  self.variables = condition['variables'] if condition and condition.get('variables') else {}
@@ -88,16 +91,6 @@ class Condition:
88
91
  field_name = part['field']
89
92
  field = self.table.fields.get(field_name)
90
93
 
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
94
  # Functions are called differently
102
95
  if part['type'] == 'function':
103
96
  expression = getattr(attr, part['function'])()
@@ -1,11 +1,11 @@
1
1
  __all__ = ['KeyCondition']
2
2
 
3
- from typing import Dict, Any
3
+ from typing import Dict, Any, Tuple, Union
4
4
 
5
- from boto3.dynamodb.conditions import Key, ConditionExpressionBuilder
5
+ from boto3.dynamodb.conditions import Key, ConditionExpressionBuilder, ComparisonCondition
6
6
 
7
7
  import nuql
8
- from nuql import resources
8
+ from nuql import resources, types
9
9
 
10
10
 
11
11
  KEY_OPERANDS = {
@@ -59,20 +59,88 @@ class KeyCondition:
59
59
  condition = {}
60
60
 
61
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)
62
+ self.index = self._resolve_index(table, index_name)
67
63
 
68
64
  pk_field = table.fields[self.index['hash']]
69
65
  sk_field = table.fields.get(self.index.get('sort'))
70
66
 
67
+ # Key fields that only contain fixed values should always be included
71
68
  if pk_field.auto_include_key_condition:
72
69
  condition[self.index['hash']] = None
73
- if sk_field and sk_field.auto_include_key_condition:
70
+ if sk_field.auto_include_key_condition:
74
71
  condition[self.index.get('sort')] = None
75
72
 
73
+ parsed_conditions = self.parse_conditions(table, condition, index_name)
74
+ self.condition = self.build_condition_expression(table, parsed_conditions, index_name)
75
+
76
+ @property
77
+ def resource_args(self) -> Dict[str, Any]:
78
+ """Query request args."""
79
+ if self.condition is None:
80
+ raise nuql.NuqlError(code='KeyConditionError', message='Key condition is empty.')
81
+
82
+ args: Dict[str, Any] = {'KeyConditionExpression': self.condition}
83
+ if self.index_name != 'primary':
84
+ args['IndexName'] = self.index_name
85
+
86
+ return args
87
+
88
+ @property
89
+ def client_args(self) -> Dict[str, Any]:
90
+ """Boto3 client args for the condition."""
91
+ if self.condition is None:
92
+ raise nuql.NuqlError(code='KeyConditionError', message='Key condition is empty.')
93
+
94
+ args = {}
95
+
96
+ if self.index_name != 'primary':
97
+ args['IndexName'] = self.index_name
98
+
99
+ builder = ConditionExpressionBuilder()
100
+ expression = builder.build_expression(self.condition, is_key_condition=True)
101
+
102
+ args['KeyConditionExpression'] = getattr(expression, 'condition_expression')
103
+ args['ExpressionAttributeNames'] = getattr(expression, 'attribute_name_placeholders')
104
+ args['ExpressionAttributeValues'] = getattr(expression, 'attribute_value_placeholders')
105
+
106
+ return args
107
+
108
+ @staticmethod
109
+ def extract_condition(key: str, value: Any) -> Tuple[str, Any]:
110
+ """
111
+ Parses and extracts the operand and value from the condition.
112
+
113
+ :arg key: Condition key.
114
+ :arg value: Condition value or dict.
115
+ :return: Tuple containing the operand and value.
116
+ """
117
+ value_keys = list(value.keys()) if isinstance(value, dict) else []
118
+ if isinstance(value, dict) and all([x.lower() in KEY_OPERANDS for x in value.keys()]):
119
+ if len(value_keys) > 1:
120
+ raise nuql.NuqlError(
121
+ code='KeyConditionError',
122
+ message=f'Multiple operators provided for the key \'{key}\' (' + ', '.join(value_keys) + ').'
123
+ )
124
+
125
+ condition_dict = next(iter(value.items()))
126
+
127
+ operand = KEY_OPERANDS[condition_dict[0].lower()]
128
+ condition_value = condition_dict[1]
129
+ else:
130
+ operand = 'eq'
131
+ condition_value = value
132
+
133
+ return operand, condition_value
134
+
135
+ def parse_conditions(self, table: 'resources.Table', condition: Dict[str, Any], index_name: str) -> Dict[str, Any]:
136
+ """
137
+ Parse the condition dict handling projected fields.
138
+
139
+ :arg table: Table instance.
140
+ :arg condition: Condition dict.
141
+ :arg index_name: Index name.
142
+ :return: Parsed condition dict.
143
+ """
76
144
  parsed_conditions = {}
77
145
 
78
146
  for key, value in condition.items():
@@ -87,8 +155,15 @@ class KeyCondition:
87
155
  projects_to_hash = self.index['hash'] in field.projected_from
88
156
  projects_to_sort = self.index.get('sort') in field.projected_from
89
157
 
158
+ # Resolve projected fields to their respective key(s)
159
+ projected_keys = []
160
+ if projects_to_hash:
161
+ projected_keys.append(self.index['hash'])
162
+ if projects_to_sort:
163
+ projected_keys.append(self.index['sort'])
164
+
90
165
  # Validate that the key is available on the index
91
- if not any([is_hash_key, is_sort_key, projects_to_hash, projects_to_sort]):
166
+ if not is_hash_key and not is_sort_key and not projects_to_hash and not projects_to_sort:
92
167
  raise nuql.NuqlError(
93
168
  code='KeyConditionError',
94
169
  message=f'Field \'{key}\' cannot be used in a key condition on \'{index_name}\' index '
@@ -103,16 +178,14 @@ class KeyCondition:
103
178
 
104
179
  # Process projected field
105
180
  else:
106
- projected_keys = []
107
- if projects_to_hash:
108
- projected_keys.append(self.index['hash'])
109
- if projects_to_sort:
110
- projected_keys.append(self.index['sort'])
111
-
112
181
  for key_name in projected_keys:
113
182
  if key_name not in parsed_conditions:
114
183
  parsed_conditions[key_name] = ['eq', {}]
115
184
 
185
+ parsed_conditions[key_name][1][key] = condition_value
186
+
187
+ # Allow a key with projections to have a greater_than or begins_with operator
188
+ # without disrupting the integrity of the condition
116
189
  if parsed_conditions[key_name][0] != 'eq' and operand != 'eq':
117
190
  raise nuql.NuqlError(
118
191
  code='KeyConditionError',
@@ -120,102 +193,94 @@ class KeyCondition:
120
193
  'will result in an ambiguous key condition.'
121
194
  )
122
195
 
196
+ # The first non-equals operand becomes the winner
123
197
  if operand != 'eq':
124
198
  parsed_conditions[key_name][0] = operand
125
199
 
126
- parsed_conditions[key_name][1][key] = condition_value
200
+ if self.index['hash'] not in parsed_conditions:
201
+ raise nuql.NuqlError(
202
+ code='KeyConditionError',
203
+ message=f'Hash key \'{self.index["hash"]}\' is required in the key condition '
204
+ f'but was not provided nor could be inferred from the schema.'
205
+ )
206
+
207
+ return parsed_conditions
208
+
209
+ def build_condition_expression(
210
+ self,
211
+ table: 'resources.Table',
212
+ parsed_conditions: Dict[str, Any],
213
+ index_name: str
214
+ ) -> ComparisonCondition:
215
+ """
216
+ Builds the final condition expression to be used in the query.
127
217
 
128
- self.condition = None
218
+ :arg table: Table instance.
219
+ :arg parsed_conditions: Parsed conditions dict.
220
+ :arg index_name: Index name.
221
+ :return: ComparisonCondition instance.
222
+ """
223
+ condition = None
129
224
  validator = resources.Validator()
130
225
 
131
226
  # Generate key condition
132
227
  for key, (operand, value) in parsed_conditions.items():
228
+
133
229
  field = table.fields[key]
230
+
134
231
  key_obj = Key(key)
232
+ key_condition_args = set()
135
233
 
234
+ # Special case for the between operator
136
235
  if operand == 'between':
137
236
  if len(value) != 2:
138
237
  raise nuql.NuqlError(
139
238
  code='KeyConditionError',
140
239
  message=f'Between operator requires exactly two values for the key \'{key}\'.'
141
240
  )
142
- serialised_value = (field(value[0], 'query', validator), field(value[1], 'query', validator))
143
- key_condition = getattr(key_obj, operand)(*serialised_value)
241
+ key_condition_args.add(field(value[0], 'query', validator))
242
+ key_condition_args.add(field(value[1], 'query', validator))
243
+
244
+ # All other operators use a single value
144
245
  else:
145
- serialised_value = field(value, 'query', validator)
146
- key_condition = getattr(key_obj, operand)(serialised_value)
246
+ key_condition_args.add(field(value, 'query', validator))
147
247
 
148
248
  is_partial = key in validator.partial_keys
149
249
 
250
+ # Disallow partial keys on hash keys
150
251
  if key == self.index['hash'] and is_partial:
151
252
  raise nuql.NuqlError(
152
253
  code='KeyConditionError',
153
- message=f'Partial key \'{key}\' cannot be used in a key condition on \'{index_name}\' index.'
254
+ message=f'Partial key \'{key}\' cannot be used in a key condition on \'{index_name}\' '
255
+ f'index as it is the hash key for the index.'
154
256
  )
155
257
 
156
- if is_partial and operand != 'begins_with':
157
- continue
158
-
159
- if self.condition is None:
160
- self.condition = key_condition
161
- else:
162
- self.condition &= key_condition
163
-
164
- @property
165
- def resource_args(self) -> Dict[str, Any]:
166
- """Query request args."""
167
- if self.condition is None:
168
- raise nuql.NuqlError(code='KeyConditionError', message='Key condition is empty.')
169
-
170
- args: Dict[str, Any] = {'KeyConditionExpression': self.condition}
171
- if self.index_name != 'primary':
172
- args['IndexName'] = self.index_name
173
-
174
- return args
175
-
176
- @property
177
- def client_args(self) -> Dict[str, Any]:
178
- """Boto3 client args for the condition."""
179
- if self.condition is None:
180
- raise nuql.NuqlError(code='KeyConditionError', message='Key condition is empty.')
181
-
182
- args = {}
183
-
184
- if self.index_name != 'primary':
185
- args['IndexName'] = self.index_name
186
-
187
- builder = ConditionExpressionBuilder()
188
- expression = builder.build_expression(self.condition, is_key_condition=True)
189
-
190
- args['KeyConditionExpression'] = getattr(expression, 'condition_expression')
191
- args['ExpressionAttributeNames'] = getattr(expression, 'attribute_name_placeholders')
192
- args['ExpressionAttributeValues'] = getattr(expression, 'attribute_value_placeholders')
193
-
194
- return args
195
-
196
- @staticmethod
197
- def extract_condition(key: str, value: Any) -> (str, Any):
198
- """
199
- Parses and extracts the operand and value from the condition.
200
-
201
- :arg key: Condition key.
202
- :arg value: Condition value or dict.
203
- :return: Tuple containing the operand and value.
204
- """
205
- value_keys = list(value.keys()) if isinstance(value, dict) else []
206
- if isinstance(value, dict) and all([x.lower() in KEY_OPERANDS for x in value.keys()]):
207
- if len(value_keys) > 1:
258
+ # Partial sort key is allowed, but we must switch the operand to begins_with
259
+ if is_partial and operand == 'eq':
260
+ operand = 'begins_with'
261
+ elif is_partial and operand != 'begins_with':
208
262
  raise nuql.NuqlError(
209
263
  code='KeyConditionError',
210
- message=f'Multiple operators provided for the key \'{key}\' (' + ', '.join(value_keys) + ').'
264
+ message=f'Operator \'{operand}\' is not supported for the key \'{key}\' '
265
+ f'as it results in a partial key. Only \'begins_with\' is supported.'
211
266
  )
212
267
 
213
- condition_dict = next(iter(value.items()))
268
+ key_condition = getattr(key_obj, operand)(*key_condition_args)
214
269
 
215
- operand = KEY_OPERANDS[condition_dict[0].lower()]
216
- condition_value = condition_dict[1]
217
- else:
218
- operand = 'eq'
219
- condition_value = value
270
+ if condition is None:
271
+ condition = key_condition
272
+ else:
273
+ condition &= key_condition
220
274
 
221
- return operand, condition_value
275
+ return condition
276
+
277
+ @staticmethod
278
+ def _resolve_index(
279
+ table: 'resources.Table',
280
+ index_name: str
281
+ ) -> Union['types.PrimaryIndex', 'types.SecondaryIndex',]:
282
+ """Resolves the index to query against"""
283
+ if index_name == 'primary':
284
+ return table.indexes.primary
285
+ else:
286
+ return table.indexes.get_index(index_name)
nuql/api/query/query.py CHANGED
@@ -12,7 +12,7 @@ class Query(api.Boto3Adapter):
12
12
  def prepare_client_args(
13
13
  self,
14
14
  key_condition: Dict[str, Any] | None = None,
15
- condition: Dict[str, Any] | None = None,
15
+ condition: str | Dict[str, Any] | None = None,
16
16
  index_name: str = 'primary',
17
17
  limit: int | None = None,
18
18
  scan_index_forward: bool = True,
@@ -52,7 +52,7 @@ class Query(api.Boto3Adapter):
52
52
  def prepare_args(
53
53
  self,
54
54
  key_condition: Dict[str, Any] | None = None,
55
- condition: Dict[str, Any] | None = None,
55
+ condition: str | Dict[str, Any] | None = None,
56
56
  index_name: str = 'primary',
57
57
  limit: int | None = None,
58
58
  scan_index_forward: bool = True,
@@ -92,7 +92,7 @@ class Query(api.Boto3Adapter):
92
92
  def invoke_sync(
93
93
  self,
94
94
  key_condition: Dict[str, Any] | None = None,
95
- condition: Dict[str, Any] | None = None,
95
+ condition: str | Dict[str, Any] | None = None,
96
96
  index_name: str = 'primary',
97
97
  limit: int | None = None,
98
98
  scan_index_forward: bool = True,
nuql/api/transaction.py CHANGED
@@ -66,7 +66,7 @@ class Transaction:
66
66
  self,
67
67
  table: 'resources.Table',
68
68
  data: Dict[str, Any],
69
- condition: Dict[str, Any] | None = None,
69
+ condition: str | Dict[str, Any] | None = None,
70
70
  ) -> None:
71
71
  """
72
72
  Create a new item on a table as part of a transaction.
@@ -86,7 +86,7 @@ class Transaction:
86
86
  self,
87
87
  table: 'resources.Table',
88
88
  data: Dict[str, Any],
89
- condition: Dict[str, Any] | None = None,
89
+ condition: str | Dict[str, Any] | None = None,
90
90
  shallow: bool = False,
91
91
  ) -> None:
92
92
  """
@@ -108,7 +108,7 @@ class Transaction:
108
108
  self,
109
109
  table: 'resources.Table',
110
110
  key: Dict[str, Any],
111
- condition: Dict[str, Any] | None = None,
111
+ condition: str | Dict[str, Any] | None = None,
112
112
  ) -> None:
113
113
  """
114
114
  Delete an item on a table as part of a transaction.
@@ -128,7 +128,7 @@ class Transaction:
128
128
  self,
129
129
  table: 'resources.Table',
130
130
  key: Dict[str, Any],
131
- condition: Dict[str, Any],
131
+ condition: str | Dict[str, Any],
132
132
  ) -> None:
133
133
  """
134
134
  Perform a condition check on an item as part of a transaction.
nuql/fields/key.py CHANGED
@@ -30,6 +30,7 @@ class Key(resources.FieldBase):
30
30
 
31
31
  # Skip fixed value fields
32
32
  if not projected_name:
33
+ auto_include_map[key] = True
33
34
  continue
34
35
 
35
36
  # Validate projected key exists on the table
@@ -105,11 +106,17 @@ class Key(resources.FieldBase):
105
106
  f'\'{self.name}\') is not defined in the schema'
106
107
  )
107
108
 
108
- is_partial = is_partial or (key not in key_dict and not projected_field.default)
109
-
110
109
  projected_value = key_dict.get(projected_name) or EmptyValue()
111
110
  serialised_value = projected_field(projected_value, action, validator)
112
- used_value = s(serialised_value) if serialised_value else None
111
+
112
+ is_partial = (is_partial or
113
+ (key not in key_dict and not projected_field.default) or
114
+ isinstance(projected_value, EmptyValue))
115
+
116
+ if isinstance(projected_value, EmptyValue):
117
+ break
118
+
119
+ used_value = s(serialised_value) if not isinstance(serialised_value, (type(None), EmptyValue)) else None
113
120
  else:
114
121
  used_value = s(value)
115
122
 
nuql/fields/string.py CHANGED
@@ -91,28 +91,30 @@ class String(resources.FieldBase):
91
91
  """
92
92
  Serialises a template string.
93
93
 
94
+ If a required projected value is missing (no user value and no default),
95
+ the output is truncated right before that placeholder and the result is marked partial.
96
+
94
97
  :arg value: Dict of projections.
95
98
  :arg action: Serialisation type.
96
99
  :arg validator: Validator instance.
97
- :return: String value.
100
+ :return: Dict with 'value' and 'is_partial'.
98
101
  """
99
102
  if not isinstance(value, dict):
100
103
  value = {}
101
104
 
102
105
  is_partial = False
106
+ template_str = self.value or ""
107
+ output_parts: list[str] = []
103
108
 
104
- # Add not provided keys as empty strings
105
- for key in self.find_projections(self.value):
106
- if key not in value:
107
- is_partial = True
108
- value[key] = None
109
+ last_idx = 0
109
110
 
110
- serialised = {}
111
+ # Walk through template pieces and placeholders in order
112
+ for match in re.finditer(TEMPLATE_PATTERN, template_str):
113
+ key = match.group(1)
114
+ # Append literal chunk before this placeholder
115
+ literal_chunk = template_str[last_idx:match.start()]
111
116
 
112
- # Serialise values before substituting
113
- for key, deserialised_value in value.items():
114
117
  field = self.parent.fields.get(key)
115
-
116
118
  if not field:
117
119
  raise nuql.NuqlError(
118
120
  code='TemplateStringError',
@@ -120,11 +122,32 @@ class String(resources.FieldBase):
120
122
  f'\'{self.name}\') is not defined in the schema'
121
123
  )
122
124
 
123
- serialised_value = field(deserialised_value or EmptyValue(), action, validator)
124
- serialised[key] = serialised_value if serialised_value else ''
125
+ provided = key in value
126
+ provided_value = value.get(key)
127
+
128
+ # If not provided and no default, we mark partial and stop right before this placeholder
129
+ if (not provided) and (field.default is None):
130
+ is_partial = True
131
+ # Keep only what we have so far and the literal up to this point
132
+ output_parts.append(literal_chunk)
133
+ break
134
+
135
+ # Serialise the projected value (use EmptyValue to allow defaults)
136
+ serialised_value = field(provided_value or EmptyValue(), action, validator)
137
+ serialised_text = serialised_value if serialised_value else ''
138
+
139
+ # Append literal + substituted value
140
+ output_parts.append(literal_chunk)
141
+ output_parts.append(str(serialised_text))
142
+
143
+ # Advance past this placeholder
144
+ last_idx = match.end()
145
+
146
+ # If not partial, append the remaining tail literal
147
+ if not is_partial:
148
+ output_parts.append(template_str[last_idx:])
125
149
 
126
- template = Template(self.value)
127
- return {'value': template.substitute(serialised), 'is_partial': is_partial}
150
+ return {'value': ''.join(output_parts), 'is_partial': is_partial}
128
151
 
129
152
  def deserialise_template(self, value: str | None) -> Dict[str, Any]:
130
153
  """
nuql/fields/ulid.py CHANGED
@@ -1,25 +1,34 @@
1
1
  __all__ = ['Ulid']
2
2
 
3
- from ulid import ULID
3
+ from typing import Any
4
4
 
5
+ import nuql
5
6
  from nuql import resources
6
7
 
7
8
 
8
9
  class Ulid(resources.FieldBase):
9
10
  type = 'ulid'
10
11
 
11
- def serialise(self, value: ULID | str | None) -> str | None:
12
+ def serialise(self, value: Any) -> str | None:
12
13
  """
13
14
  Serialises a ULID value.
14
15
 
15
16
  :arg value: ULID, str or None.
16
17
  :return: str or None.
17
18
  """
18
- if isinstance(value, ULID):
19
+ try:
20
+ import ulid
21
+ except ImportError:
22
+ raise nuql.NuqlError(
23
+ code='DependencyError',
24
+ message='The "python-ulid" package is required to use the ULID field.'
25
+ ) from None
26
+
27
+ if isinstance(value, ulid.ULID):
19
28
  return str(value)
20
29
  if isinstance(value, str):
21
30
  try:
22
- return str(ULID.from_str(value))
31
+ return str(ulid.ULID.from_str(value))
23
32
  except ValueError:
24
33
  return None
25
34
  return None
@@ -31,9 +40,17 @@ class Ulid(resources.FieldBase):
31
40
  :arg value: str or None.
32
41
  :return: str or None.
33
42
  """
43
+ try:
44
+ import ulid
45
+ except ImportError:
46
+ raise nuql.NuqlError(
47
+ code='DependencyError',
48
+ message='The "python-ulid" package is required to use the ULID field.'
49
+ ) from None
50
+
34
51
  if isinstance(value, str):
35
- # try:
36
- return str(ULID.from_str(value))
37
- # except ValueError:
38
- # return None
52
+ try:
53
+ return str(ulid.ULID.from_str(value))
54
+ except ValueError:
55
+ return None
39
56
  return None
nuql/fields/uuid.py CHANGED
@@ -1,7 +1,6 @@
1
1
  __all__ = ['Uuid']
2
2
 
3
- from uuid import UUID as NATIVE_UUID
4
- from uuid_utils import UUID
3
+ from uuid import UUID
5
4
 
6
5
  from nuql import resources
7
6
 
@@ -9,23 +8,17 @@ from nuql import resources
9
8
  class Uuid(resources.FieldBase):
10
9
  type = 'uuid'
11
10
 
12
- def serialise(self, value: NATIVE_UUID | UUID | str | None) -> str | None:
11
+ def serialise(self, value: UUID | str | None) -> str | None:
13
12
  """
14
13
  Serialises a UUID value.
15
14
 
16
15
  :arg value: UUID, str or None.
17
16
  :return: str or None.
18
17
  """
19
- if isinstance(value, (NATIVE_UUID, UUID)):
20
- return str(value)
21
-
22
- if isinstance(value, str):
23
- try:
24
- return str(UUID(value))
25
- except ValueError:
26
- return None
27
-
28
- return None
18
+ try:
19
+ return str(UUID(value))
20
+ except (AttributeError, ValueError, TypeError):
21
+ return None
29
22
 
30
23
  def deserialise(self, value: str | None) -> str | None:
31
24
  """
@@ -37,6 +30,6 @@ class Uuid(resources.FieldBase):
37
30
  if isinstance(value, str):
38
31
  try:
39
32
  return str(UUID(value))
40
- except ValueError:
33
+ except (AttributeError, ValueError, TypeError):
41
34
  return None
42
35
  return None
nuql/generators/ulid.py CHANGED
@@ -1,10 +1,19 @@
1
- from ulid import ULID
1
+ from nuql import NuqlError
2
2
 
3
3
 
4
4
  class Ulid:
5
5
  @classmethod
6
6
  def now(cls):
7
7
  """Generates current ULID"""
8
+ try:
9
+ import ulid
10
+ except ImportError:
11
+ raise NuqlError(
12
+ code='DependencyError',
13
+ message='The "python-ulid" package is required to use the ULID field.'
14
+ )
15
+
8
16
  def generator():
9
- return ULID()
17
+ return ulid.ULID()
18
+
10
19
  return generator
nuql/generators/uuid.py CHANGED
@@ -1,12 +1,12 @@
1
1
  __all__ = ['Uuid']
2
2
 
3
- from uuid_utils import uuid4, uuid7
4
-
5
3
 
6
4
  class Uuid:
7
5
  @classmethod
8
6
  def v4(cls):
9
7
  """Generates a random UUID v4"""
8
+ from uuid import uuid4
9
+
10
10
  def generator():
11
11
  return uuid4()
12
12
  return generator
@@ -14,6 +14,12 @@ class Uuid:
14
14
  @classmethod
15
15
  def v7(cls):
16
16
  """Generates a random UUID v7"""
17
+ try:
18
+ from uuid_utils import uuid7
19
+ except ImportError:
20
+ raise ImportError('Dependency "uuid_utils" must be installed to use UUID v7 generation.')
21
+
17
22
  def generator():
18
23
  return uuid7()
24
+
19
25
  return generator
@@ -96,10 +96,7 @@ class FieldBase:
96
96
  value = self.default
97
97
 
98
98
  # Serialise the value
99
- if not isinstance(value, (type(None), resources.EmptyValue)):
100
- value = self.serialise_internal(value, action, validator)
101
- else:
102
- value = None
99
+ value = self.serialise_internal(value, action, validator)
103
100
 
104
101
  # Validate required field
105
102
  if self.required and action == 'create' and value is None:
@@ -45,6 +45,9 @@ class Serialiser:
45
45
  projections = resources.Projections(self.parent, self)
46
46
  output = {}
47
47
 
48
+ if not isinstance(data, dict):
49
+ data = {}
50
+
48
51
  # Serialise provided fields
49
52
  for key, deserialised_value in data.items():
50
53
  field = self.get_field(key)
@@ -123,6 +126,9 @@ class Serialiser:
123
126
  """
124
127
  record = {}
125
128
 
129
+ if not isinstance(data, dict):
130
+ data = {}
131
+
126
132
  for name, field in self.parent.fields.items():
127
133
  # Special Case: string templates
128
134
  if hasattr(field, 'deserialise_template') and getattr(field, 'is_template', True):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nuql
3
- Version: 0.0.6
3
+ Version: 0.0.7
4
4
  Summary: Nuql (pronounced 'nuckle') is a lightweight DynamoDB library for implementing the single table model pattern.
5
5
  License: MIT License
6
6
 
@@ -30,5 +30,3 @@ Classifier: Programming Language :: Python :: 3
30
30
  Requires-Python: >=3.13
31
31
  Requires-Dist: boto3>=1.40.0
32
32
  Requires-Dist: pyparsing>=3.2.3
33
- Requires-Dist: python-ulid>=3.0.0
34
- Requires-Dist: uuid-utils>=0.11.0
@@ -4,23 +4,23 @@ nuql/connection.py,sha256=6PusMgUwPoOT8X0CqWLKdgsdvdAEwmb_dfelz0WpJE8,1471
4
4
  nuql/exceptions.py,sha256=1dhtHoLJZyP-njCPC2hNTK48tSllbGV7amlwq3tJQps,2044
5
5
  nuql/api/__init__.py,sha256=DKxuIAjckS1vjMIPykbI3h81HszlBB9LdwQuFf53-wo,324
6
6
  nuql/api/adapter.py,sha256=VIrItHyAW87xGgjavXRiN2-99shRjDn0kik_iLHeEl0,1355
7
- nuql/api/batch_write.py,sha256=IZi349BdEiahmWqtQlch4lN6yXNi921czxNTBQIzbzY,2998
8
- nuql/api/condition_check.py,sha256=uSB01Gp3ZwSWPFLJk6pZDv8zXF_QCZFLVu9yDNq2Vcs,1588
7
+ nuql/api/batch_write.py,sha256=QqtFtLVpZvcy8eyizW3jSjVO_B7D_ev3TJxnd8ppU-c,3233
8
+ nuql/api/condition_check.py,sha256=-okjA4NQPNMjA2If48X_Rxe3RKFN0AWMH9L5QT9Ln1Y,1594
9
9
  nuql/api/create.py,sha256=SUfsZtRrp1WWILsdwvSGXKdSGIiiRFsKlttX8uS8AMw,793
10
- nuql/api/delete.py,sha256=8d7LP2SLje9YHNXrCQNbt3IS67xGMtPLY5ZOx7NtJrg,3038
10
+ nuql/api/delete.py,sha256=YWHCwYbnx27PE1ZUjn_19w3gTHsmoZRWcDila9eux7c,3044
11
11
  nuql/api/get.py,sha256=8K29b-wRW4szYInPvdgyTe7lq3SsvQf1VaiABxsaw_0,934
12
- nuql/api/put_item.py,sha256=JPGJABDZSDtzWOLLB93Cf6g2l_pFTtWbwQ-2kJ6sUxI,4140
12
+ nuql/api/put_item.py,sha256=fBW0rsQ6qIHyKUrkbc5rINtBUQtQ9x79w_DksausT_A,4158
13
13
  nuql/api/put_update.py,sha256=ZkNUjX3GkBrymVenoI4RHmjn5Xzh4u1YcRxTte9X22Q,795
14
- nuql/api/transaction.py,sha256=tL7S_AvKXpcH0OwYh3Fn8N_TWDsGy3mihyjyM6gT5bg,4687
14
+ nuql/api/transaction.py,sha256=C9gF4i2HmPDoIHIBQUbVP2bfW0NaYoHTIMnc_1IAavg,4711
15
15
  nuql/api/upsert.py,sha256=X7Qwg_1x8-RUUlQZUq6qLKzlzapOLyKMlFBiDMN8QOg,1133
16
16
  nuql/api/batch_get/__init__.py,sha256=N1NQ_WFov4bANyCdS1HBPaPYnlDhZ4QEJSMiEN0sL0k,48
17
17
  nuql/api/batch_get/batch_get.py,sha256=DgGCYLQqiCUzwZdrf1XyWHEecY4nzQ-mweWhLPe68RQ,1069
18
18
  nuql/api/batch_get/queue.py,sha256=6S70p0i4xXlh8pU8X0-F8-U8P3uzoxJDzbUNaMEhrzU,4032
19
19
  nuql/api/query/__init__.py,sha256=_YhXEUvd82bBhOlkBHtPYC8a8VgCHskeg4l95X8w2O0,112
20
- nuql/api/query/condition.py,sha256=SNCEJZah7v2ioSfxHUmco73HzuKCKVvrzDx6kSLMBQY,5965
20
+ nuql/api/query/condition.py,sha256=9FxV8rtAyuD_v1tPN20UZztY21PqJkgv3loJc-_6Aqc,5615
21
21
  nuql/api/query/condition_builder.py,sha256=kHlpSYcrEX-vQvkqjOG6GVC2_a63zJSjJgIs2_zhGIo,6201
22
- nuql/api/query/key_condition.py,sha256=TgOIOC8lBgw2LJlK0nqWh3mxXqQUhiUhoi1leyLvKM4,8046
23
- nuql/api/query/query.py,sha256=Wb3TPmzUdnN96KV_kyXzrVmOsqrVbxFUv3sE48AZxuk,6365
22
+ nuql/api/query/key_condition.py,sha256=UlxYjvgH9iVors7oEToHkl4vK_IDqbnQKro1bk6LBp0,10845
23
+ nuql/api/query/query.py,sha256=90ieaZGwWXhpamJBX67y3SDA0oRaNf5YMyxjLWhz8W4,6383
24
24
  nuql/api/update/__init__.py,sha256=15mjrAPhrOM74c3Q6NQXTGVjFFgwPCiTAlBLHIDmPQ8,85
25
25
  nuql/api/update/expression_builder.py,sha256=jFkp0EASDx7uK2b0TanRALGusXqtXwvOVy02WW2OfGM,1147
26
26
  nuql/api/update/update_item.py,sha256=KCfrAZiDPmQc0k20UAA9H3dw_8WFYJ8fDOqHb_xSYNo,5295
@@ -31,24 +31,24 @@ nuql/fields/datetime.py,sha256=4K28yydnzUc1HsMH-lgSSNOx2NbEL4qXC3kTpuRBVis,1386
31
31
  nuql/fields/datetime_timestamp.py,sha256=drJLpmGhL8ANrfGsArKeVLb4v7maiignhzC4qpQzMHY,1275
32
32
  nuql/fields/float.py,sha256=x48Spo_GNku0djO0Ogs8usZTfwOY-27Mh5OPOQBO3OY,1316
33
33
  nuql/fields/integer.py,sha256=pHs3CJM4xKN60IYg5aTe4QjSQQBR4jp08HyANtidHgs,1312
34
- nuql/fields/key.py,sha256=r2Q4qVlAS51dOUI7Zk_OTr08VKkMBXNyUJb3ieY-nnY,6621
34
+ nuql/fields/key.py,sha256=vG_yj4Lkiz3bsD2f31HEVgU5lEde_gTYk4awQ78gko8,6912
35
35
  nuql/fields/list.py,sha256=m7wTIb4-VRRKO5C8XZJFeGOPHIqrvc3xNbWw-vHppQM,1508
36
36
  nuql/fields/map.py,sha256=jFbJ9YhvVMrkLlzktMEQOrZw7uqOLgdfmwxuw9Zue-Y,1169
37
- nuql/fields/string.py,sha256=-7yk6T3HrKn52KdAyqoXqgtPdzpZKCCcbYR-TTtTjfk,5519
38
- nuql/fields/ulid.py,sha256=mcwTztzIqE_3q1cRa2pf6pN8s0322NQlfQcky9xAWkI,971
39
- nuql/fields/uuid.py,sha256=fhwAcMUHuih2NgPh7QOs4hcCwrl8Cu2CqSpvLMBAV-c,1041
37
+ nuql/fields/string.py,sha256=ITzRSuDGomBfRhcbHAVkfRQ7j2kDwBBFPdkzQzVNI1w,6629
38
+ nuql/fields/ulid.py,sha256=GSaEbi31_gUnZzx-MD-pCR8H_fNCEaLwwnWLxRg4JbI,1498
39
+ nuql/fields/uuid.py,sha256=iv-_P8W3MRBrt41R8gMsZTEoFF1eS3deoueCgmznUz4,881
40
40
  nuql/generators/__init__.py,sha256=YgTwNlvZYM4EYo2u3gSN4bIuXz7f8SQF7Y7jnpXN_Ls,67
41
41
  nuql/generators/datetime.py,sha256=eX10E9mbCaFVJb3xsVaNxTCpBpl1lsd6HTGYS6hsqNs,981
42
- nuql/generators/ulid.py,sha256=MuxmKQBPEBSqhlD4NPCnUrDgwgn82a0sTQISQ0iIYmM,194
43
- nuql/generators/uuid.py,sha256=MoEQVxgEsqGlhpf4W-OuRiLKqNwRtE2NtS3eFTOc5lo,394
42
+ nuql/generators/ulid.py,sha256=la23skED6VbkqcrxZ16j0RPw8vgy8EM1n33jC2ztf4o,450
43
+ nuql/generators/uuid.py,sha256=ZxATeYevYhrJu6MceEK-CtNjRj0eBzoacv95icHh-2s,581
44
44
  nuql/resources/__init__.py,sha256=8HRJ_H5yTdQtfeVZ29ikdbLQSPKiZRyNkoocQd5QtZ8,92
45
45
  nuql/resources/fields/__init__.py,sha256=bajUBAvrK8kIMyb2vD_0NCwibRHOLo4Yoq1sac0Dobc,70
46
- nuql/resources/fields/field.py,sha256=5AjJbKxk1GwgP7nhMuZOPKQYFhlDTA9VrHeBF4mQcAA,5589
46
+ nuql/resources/fields/field.py,sha256=NSynnwHwrKk7SaCvzpb2Zsjeu1LFNCfk0QR6cIKVvHo,5473
47
47
  nuql/resources/fields/field_map.py,sha256=bOzoBFbB2XOyLfnqaVEL_BDwt3S9_oUGIl1N0awgZ7g,2519
48
48
  nuql/resources/fields/value.py,sha256=CG3PnGeSMHDQciIMR6jkMhogCJZhpEfipLdyk9gnjJw,59
49
49
  nuql/resources/records/__init__.py,sha256=gD8-xt4uIOpx_ZTnw1QTzOiwUpjKhR3nhkZw4AmEvaI,81
50
50
  nuql/resources/records/projections.py,sha256=PTwoc8-XXieXsYw0H-3Fv7G8SzSN__j35QsqempXBj8,1618
51
- nuql/resources/records/serialiser.py,sha256=yhMUZ-E0NMMxDszOSfl9ZWNdc2mSJDZfSIjUxAGKnLM,5039
51
+ nuql/resources/records/serialiser.py,sha256=5vqLjNzY07Rrl-u_-SufImjAGCxpF8txwzlhDW0OHiE,5169
52
52
  nuql/resources/records/validator.py,sha256=d8GQN_rwnuKnV6tkEDCqswE_ARso7ZNH0vB1L2eVrj0,1558
53
53
  nuql/resources/tables/__init__.py,sha256=Bk8ewpPb_myliQPmyIarzma_Vezd4XR-Z1lABsTUMPQ,46
54
54
  nuql/resources/tables/indexes.py,sha256=5e5bQ_yRgyLmnoXuKAqEw0mJJhY_NE4Sk0vavvMG2m4,5076
@@ -60,7 +60,7 @@ nuql/types/__init__.py,sha256=Ea4KR4mUs1RNUEskKF9sXfGpoQw2V9cnIYeE20wkChs,76
60
60
  nuql/types/config.py,sha256=lFfPPe62l4DkfLXR8ecw5AiSRAFzsTYy_3a0d_1M7hY,659
61
61
  nuql/types/fields.py,sha256=LjGgXM84WO54gRDkgvnwF3pjFvFxErdGYOvs0BskF_o,883
62
62
  nuql/types/serialisation.py,sha256=XAy_gARcwYmfEuxWAJox2ClWK9ow0F5WXKVbMUVBq9w,242
63
- nuql-0.0.6.dist-info/METADATA,sha256=R8gSusfsK1exy_2UL6_uwhyN9g6SjfgR_J4n5WGbg8c,1735
64
- nuql-0.0.6.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
65
- nuql-0.0.6.dist-info/licenses/LICENSE,sha256=AS8DF6oGYsvk781m40Nec9eCkj_S_oUVAWaFakB2LMs,1097
66
- nuql-0.0.6.dist-info/RECORD,,
63
+ nuql-0.0.7.dist-info/METADATA,sha256=gLqBg1Yagk-W7bcv9Ccir4ljUXLhGT7XylyA0MTkPl8,1667
64
+ nuql-0.0.7.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
65
+ nuql-0.0.7.dist-info/licenses/LICENSE,sha256=AS8DF6oGYsvk781m40Nec9eCkj_S_oUVAWaFakB2LMs,1097
66
+ nuql-0.0.7.dist-info/RECORD,,
File without changes