clear-skies-aws 1.10.0__tar.gz → 1.10.1__tar.gz

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 (71) hide show
  1. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/PKG-INFO +1 -1
  2. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/pyproject.toml +1 -1
  3. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/backends/dynamo_db_parti_ql_backend.py +233 -110
  4. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/backends/dynamo_db_parti_ql_backend_test.py +13 -9
  5. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/LICENSE +0 -0
  6. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/README.md +0 -0
  7. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/__init__.py +0 -0
  8. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/actions/__init__.py +0 -0
  9. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/actions/action_aws.py +0 -0
  10. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/actions/assume_role.py +0 -0
  11. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/actions/assume_role_test.py +0 -0
  12. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/actions/ses.py +0 -0
  13. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/actions/ses_test.py +0 -0
  14. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/actions/sns.py +0 -0
  15. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/actions/sns_test.py +0 -0
  16. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/actions/sqs.py +0 -0
  17. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/actions/sqs_test.py +0 -0
  18. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/actions/step_function.py +0 -0
  19. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/actions/step_function_test.py +0 -0
  20. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/backends/__init__.py +0 -0
  21. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/backends/dynamo_db_backend.py +0 -0
  22. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/backends/dynamo_db_backend_test.py +0 -0
  23. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/backends/dynamo_db_condition_parser.py +0 -0
  24. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/backends/dynamo_db_condition_parser_test.py +0 -0
  25. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/backends/sqs_backend.py +0 -0
  26. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/backends/sqs_backend_test.py +0 -0
  27. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/contexts/__init__.py +0 -0
  28. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/contexts/cli.py +0 -0
  29. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/contexts/cli_websocket_mock.py +0 -0
  30. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/contexts/lambda_api_gateway.py +0 -0
  31. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/contexts/lambda_api_gateway_web_socket.py +0 -0
  32. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/contexts/lambda_elb.py +0 -0
  33. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/contexts/lambda_http_gateway.py +0 -0
  34. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/contexts/lambda_invocation.py +0 -0
  35. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/contexts/lambda_sns.py +0 -0
  36. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/contexts/lambda_sqs_standard_partial_batch.py +0 -0
  37. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/contexts/lambda_sqs_standard_partial_batch_test.py +0 -0
  38. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/contexts/wsgi.py +0 -0
  39. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/di/__init__.py +0 -0
  40. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/di/standard_dependencies.py +0 -0
  41. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/handlers/__init__.py +0 -0
  42. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/handlers/secrets_manager_rotation.py +0 -0
  43. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/handlers/simple_body_routing.py +0 -0
  44. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/input_outputs/__init__.py +0 -0
  45. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/input_outputs/cli_websocket_mock.py +0 -0
  46. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/input_outputs/lambda_api_gateway.py +0 -0
  47. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/input_outputs/lambda_api_gateway_test.py +0 -0
  48. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/input_outputs/lambda_api_gateway_web_socket.py +0 -0
  49. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/input_outputs/lambda_elb.py +0 -0
  50. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/input_outputs/lambda_http_gateway.py +0 -0
  51. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/input_outputs/lambda_invocation.py +0 -0
  52. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/input_outputs/lambda_sns.py +0 -0
  53. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/input_outputs/lambda_sqs_standard.py +0 -0
  54. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/mocks/__init__.py +0 -0
  55. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/mocks/actions/__init__.py +0 -0
  56. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/mocks/actions/ses.py +0 -0
  57. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/mocks/actions/sns.py +0 -0
  58. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/mocks/actions/sqs.py +0 -0
  59. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/mocks/actions/step_function.py +0 -0
  60. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/secrets/__init__.py +0 -0
  61. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/secrets/additional_configs/__init__.py +0 -0
  62. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/secrets/additional_configs/iam_db_auth.py +0 -0
  63. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/secrets/additional_configs/iam_db_auth_with_ssm.py +0 -0
  64. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssh_cert_bastion.py +0 -0
  65. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssm_bastion.py +0 -0
  66. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/secrets/akeyless_with_ssm_cache.py +0 -0
  67. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/secrets/parameter_store.py +0 -0
  68. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/secrets/parameter_store_test.py +0 -0
  69. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/secrets/secrets_manager.py +0 -0
  70. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/secrets/secrets_manager_test.py +0 -0
  71. {clear_skies_aws-1.10.0 → clear_skies_aws-1.10.1}/src/clearskies_aws/web_socket_connection_model.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: clear-skies-aws
3
- Version: 1.10.0
3
+ Version: 1.10.1
4
4
  Summary: clearskies bindings for working in AWS
5
5
  License: MIT
6
6
  Author: Conor Mancone
@@ -2,7 +2,7 @@
2
2
 
3
3
  [tool.poetry]
4
4
  name = "clear-skies-aws"
5
- version = "1.10.0"
5
+ version = "1.10.1"
6
6
  description = "clearskies bindings for working in AWS"
7
7
  authors = [
8
8
  "Conor Mancone <cmancone@gmail.com>",
@@ -2,7 +2,8 @@ import base64
2
2
  import binascii
3
3
  import json
4
4
  import logging
5
- from decimal import Decimal
5
+ import re
6
+ from decimal import Decimal, InvalidOperation
6
7
  from typing import Any, Callable, Dict, Generator, List, Optional, Tuple, Union
7
8
 
8
9
  from boto3.session import Session as Boto3Session
@@ -68,7 +69,10 @@ class DynamoDBPartiQLCursor:
68
69
  """
69
70
  try:
70
71
  call_args: ExecuteStatementInputTypeDef = {"Statement": statement}
71
- if parameters is not None:
72
+ # Only include 'Parameters' if it's not None AND not empty
73
+ if (
74
+ parameters
75
+ ): # This implies parameters is not None and parameters is not an empty list
72
76
  call_args["Parameters"] = parameters
73
77
  if Limit is not None:
74
78
  call_args["Limit"] = Limit
@@ -81,7 +85,7 @@ class DynamoDBPartiQLCursor:
81
85
  **call_args
82
86
  )
83
87
  except ClientError as err:
84
- error_response: Dict[str, Any] = err.response.get("Error", {})
88
+ error_response: Dict[str, Any] = err.response.get("Error", {}) # type: ignore
85
89
  error_code: str = error_response.get("Code", "UnknownCode")
86
90
  error_message: str = error_response.get("Message", "Unknown error")
87
91
 
@@ -114,6 +118,7 @@ class DynamoDBPartiQLBackend(CursorBackend):
114
118
  The count() method uses native DynamoDB Query/Scan operations for accuracy.
115
119
  """
116
120
 
121
+ _cursor: DynamoDBPartiQLCursor
117
122
  _allowed_configs: List[str] = [
118
123
  "table_name",
119
124
  "wheres",
@@ -165,7 +170,7 @@ class DynamoDBPartiQLBackend(CursorBackend):
165
170
  if not table_name:
166
171
  return ""
167
172
  esc: str = self._table_escape_character()
168
- final_name = f"{esc}{table_name.strip(esc)}{esc}"
173
+ final_name = f"{esc}{str(table_name).strip(esc)}{esc}"
169
174
  if index_name:
170
175
  final_name += f".{esc}{index_name.strip(esc)}{esc}"
171
176
  return final_name
@@ -279,13 +284,13 @@ class DynamoDBPartiQLBackend(CursorBackend):
279
284
 
280
285
  if configuration.get("group_by_column"):
281
286
  logger.warning(
282
- f"Configuration included 'group_by_column={configuration.get("group_by_column")}', "
287
+ "Configuration included 'group_by_column=" + configuration.get("group_by_column") + "', " +
283
288
  "but GROUP BY is not supported by this DynamoDB PartiQL backend and will be ignored for SQL generation."
284
289
  )
285
290
 
286
291
  if configuration.get("joins"):
287
292
  logger.warning(
288
- f"Configuration included 'joins={configuration.get("joins")}', "
293
+ "Configuration included 'joins=" + str(configuration.get("joins")) + "', " +
289
294
  "but JOINs are not supported by this DynamoDB PartiQL backend and will be ignored for SQL generation."
290
295
  )
291
296
 
@@ -363,8 +368,8 @@ class DynamoDBPartiQLBackend(CursorBackend):
363
368
  ) -> Dict[str, Any]:
364
369
  """
365
370
  Converts a list of 'where' condition dictionaries into DynamoDB native
366
- expression strings and attribute maps.
367
- This is a simplified implementation.
371
+ expression strings and attribute maps for Query/Scan operations.
372
+ This implementation is more comprehensive than the previous one.
368
373
  """
369
374
  expression_attribute_names: Dict[str, str] = {}
370
375
  expression_attribute_values: Dict[str, AttributeValueTypeDef] = {}
@@ -374,65 +379,98 @@ class DynamoDBPartiQLBackend(CursorBackend):
374
379
  name_counter = 0
375
380
  value_counter = 0
376
381
 
377
- partition_key_condition_found = False
382
+ # Helper to get unique placeholder names
383
+ def get_name_placeholder(column_name: str) -> str:
384
+ nonlocal name_counter
385
+ # Sanitize column name for placeholder if it contains special characters
386
+ sanitized_column_name = re.sub(r"[^a-zA-Z0-9_]", "", column_name)
387
+ placeholder = f"#{sanitized_column_name}_{name_counter}"
388
+ expression_attribute_names[placeholder] = column_name
389
+ name_counter += 1
390
+ return placeholder
391
+
392
+ # Helper to get unique value placeholders and add values
393
+ def get_value_placeholder(value: Any) -> str:
394
+ nonlocal value_counter
395
+ placeholder = f":val{value_counter}"
396
+ expression_attribute_values[placeholder] = (
397
+ self.condition_parser.to_dynamodb_attribute_value(value)
398
+ )
399
+ value_counter += 1
400
+ return placeholder
401
+
402
+ processed_condition_indices = set()
403
+
404
+ # First, try to build KeyConditionExpression for Partition Key and Sort Key
405
+ # Find partition key equality condition
406
+ pk_condition_index = -1
378
407
  if partition_key_name:
379
- processed_indices = set() # To avoid processing a condition twice
380
408
  for i, cond in enumerate(conditions):
381
- if i in processed_indices:
382
- continue
383
409
  if (
384
410
  cond.get("column") == partition_key_name
385
411
  and cond.get("operator") == "="
386
412
  and cond.get("values")
387
413
  ):
388
- name_placeholder = f"#pk{name_counter}"
389
- value_placeholder = f":pk_val{value_counter}"
390
- expression_attribute_names[name_placeholder] = partition_key_name
391
- expression_attribute_values[value_placeholder] = (
392
- self.condition_parser.to_dynamodb_attribute_value(
393
- cond["values"][0]
394
- )
395
- )
396
- key_condition_parts.append(
397
- f"{name_placeholder} = {value_placeholder}"
398
- )
399
- name_counter += 1
400
- value_counter += 1
401
- partition_key_condition_found = True
402
- processed_indices.add(i)
414
+ pk_condition_index = i
403
415
  break
404
416
 
405
- # Example for sort key condition (simplified)
406
- if (
407
- sort_key_name and partition_key_condition_found
408
- ): # Sort key only relevant if PK is there
417
+ if pk_condition_index != -1:
418
+ pk_cond = conditions[pk_condition_index]
419
+ pk_name_ph = get_name_placeholder(pk_cond["column"])
420
+ pk_value_ph = get_value_placeholder(pk_cond["values"][0])
421
+ key_condition_parts.append(f"{pk_name_ph} = {pk_value_ph}")
422
+ processed_condition_indices.add(pk_condition_index)
423
+
424
+ # If partition key found, check for sort key condition
425
+ if sort_key_name:
409
426
  for i, cond in enumerate(conditions):
410
- if i in processed_indices:
427
+ if i in processed_condition_indices:
411
428
  continue
412
429
  if cond.get("column") == sort_key_name and cond.get("values"):
413
- # Simplified: only handle '=' for sort key for now
414
- if cond.get("operator") == "=":
415
- name_placeholder = f"#sk{name_counter}"
416
- value_placeholder = f":sk_val{value_counter}"
417
- expression_attribute_names[name_placeholder] = sort_key_name
418
- expression_attribute_values[value_placeholder] = (
419
- self.condition_parser.to_dynamodb_attribute_value(
420
- cond["values"][0]
430
+ op_lower = cond["operator"].lower()
431
+ sk_name_ph = get_name_placeholder(cond["column"])
432
+
433
+ if op_lower == "=":
434
+ sk_value_ph = get_value_placeholder(cond["values"][0])
435
+ key_condition_parts.append(f"{sk_name_ph} = {sk_value_ph}")
436
+ elif op_lower == ">":
437
+ sk_value_ph = get_value_placeholder(cond["values"][0])
438
+ key_condition_parts.append(f"{sk_name_ph} > {sk_value_ph}")
439
+ elif op_lower == "<":
440
+ sk_value_ph = get_value_placeholder(cond["values"][0])
441
+ key_condition_parts.append(f"{sk_name_ph} < {sk_value_ph}")
442
+ elif op_lower == ">=":
443
+ sk_value_ph = get_value_placeholder(cond["values"][0])
444
+ key_condition_parts.append(f"{sk_name_ph} >= {sk_value_ph}")
445
+ elif op_lower == "<=":
446
+ sk_value_ph = get_value_placeholder(cond["values"][0])
447
+ key_condition_parts.append(f"{sk_name_ph} <= {sk_value_ph}")
448
+ elif op_lower == "between":
449
+ if len(cond["values"]) == 2:
450
+ sk_value1_ph = get_value_placeholder(cond["values"][0])
451
+ sk_value2_ph = get_value_placeholder(cond["values"][1])
452
+ key_condition_parts.append(
453
+ f"{sk_name_ph} BETWEEN {sk_value1_ph} AND {sk_value2_ph}"
421
454
  )
422
- )
455
+ else:
456
+ logger.warning(
457
+ f"Skipping malformed BETWEEN condition for sort key: {cond}"
458
+ )
459
+ elif op_lower == "begins_with":
460
+ sk_value_ph = get_value_placeholder(cond["values"][0])
423
461
  key_condition_parts.append(
424
- f"{name_placeholder} = {value_placeholder}"
462
+ f"begins_with({sk_name_ph}, {sk_value_ph})"
425
463
  )
426
- name_counter += 1
427
- value_counter += 1
428
- processed_indices.add(i)
429
- break # Assuming one sort key condition for KeyConditionExpression
464
+ else:
465
+ # Other operators for sort key are not part of KeyConditionExpression
466
+ # They will be handled in FilterExpression below
467
+ continue
468
+ processed_condition_indices.add(i)
469
+ break # Assume only one sort key condition for KeyConditionExpression
430
470
 
431
- # Remaining conditions go to FilterExpression
471
+ # Process all remaining conditions for FilterExpression
432
472
  for i, cond in enumerate(conditions):
433
- if i in (
434
- locals().get("processed_indices") or set()
435
- ): # Check if already processed for KeyCondition
473
+ if i in processed_condition_indices:
436
474
  continue
437
475
 
438
476
  col_name = cond.get("column")
@@ -442,31 +480,88 @@ class DynamoDBPartiQLBackend(CursorBackend):
442
480
  if not col_name or not op or vals is None:
443
481
  continue
444
482
 
445
- # This is a very simplified filter builder. A real one would handle all operators.
446
- if op == "=" and vals: # Example: only equality
447
- name_placeholder = f"#fn{name_counter}"
448
- value_placeholder = f":fv{value_counter}"
449
- expression_attribute_names[name_placeholder] = col_name
450
- expression_attribute_values[value_placeholder] = (
451
- self.condition_parser.to_dynamodb_attribute_value(vals[0])
452
- )
483
+ name_ph = get_name_placeholder(col_name)
484
+ op_lower = op.lower()
485
+
486
+ if op_lower == "=":
487
+ value_ph = get_value_placeholder(vals[0])
488
+ filter_expression_parts.append(f"{name_ph} = {value_ph}")
489
+ elif op_lower == "!=":
490
+ value_ph = get_value_placeholder(vals[0])
491
+ filter_expression_parts.append(f"{name_ph} <> {value_ph}")
492
+ elif op_lower == ">":
493
+ value_ph = get_value_placeholder(vals[0])
494
+ filter_expression_parts.append(f"{name_ph} > {value_ph}")
495
+ elif op_lower == "<":
496
+ value_ph = get_value_placeholder(vals[0])
497
+ filter_expression_parts.append(f"{name_ph} < {value_ph}")
498
+ elif op_lower == ">=":
499
+ value_ph = get_value_placeholder(vals[0])
500
+ filter_expression_parts.append(f"{name_ph} >= {value_ph}")
501
+ elif op_lower == "<=":
502
+ value_ph = get_value_placeholder(vals[0])
503
+ filter_expression_parts.append(f"{name_ph} <= {value_ph}")
504
+ elif op_lower == "between":
505
+ if len(vals) == 2:
506
+ value1_ph = get_value_placeholder(vals[0])
507
+ value2_ph = get_value_placeholder(vals[1])
508
+ filter_expression_parts.append(
509
+ f"{name_ph} BETWEEN {value1_ph} AND {value2_ph}"
510
+ )
511
+ else:
512
+ logger.warning(f"Skipping malformed BETWEEN condition: {cond}")
513
+ elif op_lower == "in":
514
+ value_placeholders = ", ".join([get_value_placeholder(v) for v in vals])
515
+ filter_expression_parts.append(f"{name_ph} IN ({value_placeholders})")
516
+ elif op_lower == "contains":
517
+ value_ph = get_value_placeholder(vals[0])
518
+ filter_expression_parts.append(f"contains({name_ph}, {value_ph})")
519
+ elif op_lower == "not contains":
520
+ value_ph = get_value_placeholder(vals[0])
521
+ filter_expression_parts.append(f"NOT contains({name_ph}, {value_ph})")
522
+ elif op_lower == "begins_with":
523
+ value_ph = get_value_placeholder(vals[0])
524
+ filter_expression_parts.append(f"begins_with({name_ph}, {value_ph})")
525
+ elif op_lower == "not begins_with":
526
+ value_ph = get_value_placeholder(vals[0])
453
527
  filter_expression_parts.append(
454
- f"{name_placeholder} = {value_placeholder}"
528
+ f"NOT begins_with({name_ph}, {value_ph})"
529
+ )
530
+ elif op_lower == "is null":
531
+ filter_expression_parts.append(f"attribute_not_exists({name_ph})")
532
+ elif op_lower == "is not null":
533
+ filter_expression_parts.append(f"attribute_exists({name_ph})")
534
+ elif (
535
+ op_lower == "like"
536
+ ): # Clearskies 'like' usually translates to begins_with or contains
537
+ # This is a simplification. A full implementation might need to inspect '%' position.
538
+ # For now, if it contains '%', assume 'contains'. If it ends with '%', assume 'begins_with'.
539
+ # If no '%', it's an equality.
540
+ if len(vals) > 0 and isinstance(vals[0], str):
541
+ like_value = vals[0]
542
+ if like_value.startswith("%") and like_value.endswith("%"):
543
+ value_ph = get_value_placeholder(like_value.strip("%"))
544
+ filter_expression_parts.append(
545
+ f"contains({name_ph}, {value_ph})"
546
+ )
547
+ elif like_value.endswith("%"):
548
+ value_ph = get_value_placeholder(like_value.rstrip("%"))
549
+ filter_expression_parts.append(
550
+ f"begins_with({name_ph}, {value_ph})"
551
+ )
552
+ else: # Treat as equality if no wildcards or complex pattern
553
+ value_ph = get_value_placeholder(like_value)
554
+ filter_expression_parts.append(f"{name_ph} = {value_ph}")
555
+ else:
556
+ logger.warning(f"Skipping unsupported LIKE condition: {cond}")
557
+ else:
558
+ logger.warning(
559
+ f"Skipping unsupported operator '{op}' for native DynamoDB expressions: {cond}"
455
560
  )
456
- name_counter += 1
457
- value_counter += 1
458
- # Add more operator handling here for a complete filter expression builder
459
561
 
460
562
  result: Dict[str, Any] = {}
461
- if (
462
- key_condition_parts and partition_key_condition_found
463
- ): # Only add KeyConditionExpression if PK equality was found
563
+ if key_condition_parts:
464
564
  result["KeyConditionExpression"] = " AND ".join(key_condition_parts)
465
- elif (
466
- key_condition_parts
467
- ): # If we built key_condition_parts but PK equality wasn't the one, move to filter
468
- filter_expression_parts.extend(key_condition_parts)
469
-
470
565
  if filter_expression_parts:
471
566
  result["FilterExpression"] = " AND ".join(filter_expression_parts)
472
567
  if expression_attribute_names:
@@ -495,8 +590,8 @@ class DynamoDBPartiQLBackend(CursorBackend):
495
590
  table_description.get("GlobalSecondaryIndexes", [])
496
591
  )
497
592
  for gsi in gsi_definitions:
498
- if gsi["IndexName"] == chosen_index_name:
499
- for key_element in gsi["KeySchema"]:
593
+ if gsi.get("IndexName", "") == chosen_index_name:
594
+ for key_element in gsi.get("KeySchema", []):
500
595
  if key_element["KeyType"] == "RANGE":
501
596
  sort_key_for_target = key_element["AttributeName"]
502
597
  break
@@ -516,7 +611,7 @@ class DynamoDBPartiQLBackend(CursorBackend):
516
611
  wheres_config, partition_key_for_target, sort_key_for_target
517
612
  )
518
613
 
519
- params_for_native_call: Dict[str, Any] = { # Renamed from params
614
+ params_for_native_call: Dict[str, Any] = {
520
615
  "TableName": table_name,
521
616
  "Select": "COUNT",
522
617
  }
@@ -524,36 +619,37 @@ class DynamoDBPartiQLBackend(CursorBackend):
524
619
  params_for_native_call["IndexName"] = chosen_index_name
525
620
 
526
621
  can_use_query_for_count = False
622
+ # A Query operation can be used for count if there is a KeyConditionExpression
623
+ # that includes an equality condition on the partition key of the target (table or GSI).
624
+ # We check if the partition key condition was successfully extracted into KeyConditionExpression.
527
625
  if (
528
- native_expressions.get("KeyConditionExpression")
529
- and partition_key_for_target
530
- and any(
531
- w.get("column") == partition_key_for_target and w.get("operator") == "="
532
- for w in wheres_config
533
- if isinstance(w, dict)
534
- )
626
+ partition_key_for_target
627
+ and f"#{re.sub(r'[^a-zA-Z0-9_]', '', partition_key_for_target)}_0"
628
+ in native_expressions.get("ExpressionAttributeNames", {})
629
+ and native_expressions.get("KeyConditionExpression")
630
+ and f"#{re.sub(r'[^a-zA-Z0-9_]', '', partition_key_for_target)}_0 = :val0"
631
+ in native_expressions[
632
+ "KeyConditionExpression"
633
+ ] # Simplified check, assumes first value is PK
535
634
  ):
536
635
  can_use_query_for_count = True
537
636
  params_for_native_call["KeyConditionExpression"] = native_expressions[
538
637
  "KeyConditionExpression"
539
638
  ]
540
- if native_expressions.get(
541
- "FilterExpression"
542
- ): # Query can also have FilterExpression
639
+ if native_expressions.get("FilterExpression"):
543
640
  params_for_native_call["FilterExpression"] = native_expressions[
544
641
  "FilterExpression"
545
642
  ]
546
- else: # Fall back to Scan
547
- # If KeyConditionExpression was built but not for the PK, it becomes part of Filter for Scan
548
- all_filter_parts = []
549
- if native_expressions.get("KeyConditionExpression"):
550
- all_filter_parts.append(native_expressions["KeyConditionExpression"])
643
+ else:
644
+ # Fall back to Scan, and all conditions (including any potential key conditions that
645
+ # couldn't be used for a Query) go into FilterExpression.
551
646
  if native_expressions.get("FilterExpression"):
552
- all_filter_parts.append(native_expressions["FilterExpression"])
553
- if all_filter_parts:
554
- params_for_native_call["FilterExpression"] = " AND ".join(
555
- all_filter_parts
556
- )
647
+ params_for_native_call["FilterExpression"] = native_expressions[
648
+ "FilterExpression"
649
+ ]
650
+ # If there's a KeyConditionExpression but no PK equality, it should also be part of the filter for scan.
651
+ # This logic is now handled more robustly within _wheres_to_native_dynamo_expressions
652
+ # by ensuring only true PK/SK conditions go to KeyConditionExpression initially.
557
653
 
558
654
  if native_expressions.get("ExpressionAttributeNames"):
559
655
  params_for_native_call["ExpressionAttributeNames"] = native_expressions[
@@ -599,18 +695,31 @@ class DynamoDBPartiQLBackend(CursorBackend):
599
695
  """
600
696
  Creates a new record in DynamoDB using PartiQL INSERT.
601
697
  """
602
- table_name: str = self._finalize_table_name(model.table_name) # type: ignore
698
+ table_name: str = self._finalize_table_name(model.get_table_name())
603
699
 
604
- item_to_insert: Dict[str, AttributeValueTypeDef] = {
605
- key: self.condition_parser.to_dynamodb_attribute_value(value)
606
- for key, value in data.items()
607
- }
700
+ if not data:
701
+ logger.warning("Create called with empty data. Nothing to insert.")
702
+ return {}
703
+
704
+ # Prepare parameters
705
+ parameters: List[AttributeValueTypeDef] = []
706
+
707
+ # Build the 'VALUE {key: ?, key: ?}' part and collect parameters
708
+ value_struct_parts: List[str] = []
709
+ for key, value in data.items():
710
+ # Use single quotes around the key to match PartiQL documentation examples
711
+ value_struct_parts.append(f"'{key}': ?")
712
+ parameters.append(self.condition_parser.to_dynamodb_attribute_value(value))
713
+ value_struct_clause = ", ".join(value_struct_parts)
608
714
 
609
- parameters: List[AttributeValueTypeDef] = [item_to_insert] # type: ignore
610
- statement = f"INSERT INTO {table_name} VALUE ?"
715
+ # Construct the INSERT statement with explicit struct format
716
+ statement = f"INSERT INTO {table_name} VALUE {{{value_struct_clause}}}"
611
717
 
612
718
  try:
613
- self._cursor.execute(statement=statement, parameters=parameters)
719
+ self._cursor.execute(
720
+ statement=statement,
721
+ parameters=parameters,
722
+ )
614
723
  return data
615
724
  except Exception as e:
616
725
  logger.error(
@@ -624,8 +733,8 @@ class DynamoDBPartiQLBackend(CursorBackend):
624
733
  """
625
734
  Updates an existing record in DynamoDB using PartiQL UPDATE.
626
735
  """
627
- table_name: str = self._finalize_table_name(model.table_name) # type: ignore
628
- id_column_name: str = model.id_column_name # type: ignore
736
+ table_name: str = self._finalize_table_name(model.get_table_name())
737
+ id_column_name: str = model.id_column_name
629
738
  escaped_id_column: str = (
630
739
  f"{self._column_escape_character()}{id_column_name}{self._column_escape_character()}"
631
740
  )
@@ -679,13 +788,15 @@ class DynamoDBPartiQLBackend(CursorBackend):
679
788
  """
680
789
  Deletes a record from DynamoDB using PartiQL DELETE.
681
790
  """
682
- table_name: str = self._finalize_table_name(model.table_name) # type: ignore
683
- id_column_name: str = model.id_column_name # type: ignore
791
+ table_name: str = self._finalize_table_name(model.get_table_name())
792
+ id_column_name: str = model.id_column_name
684
793
  escaped_id_column: str = (
685
794
  f"{self._column_escape_character()}{id_column_name}{self._column_escape_character()}"
686
795
  )
687
796
 
688
- parameters: List[AttributeValueTypeDef] = [self.condition_parser.to_dynamodb_attribute_value(id_value)] # type: ignore
797
+ parameters: List[AttributeValueTypeDef] = [
798
+ self.condition_parser.to_dynamodb_attribute_value(id_value)
799
+ ]
689
800
  statement: str = f"DELETE FROM {table_name} WHERE {escaped_id_column} = ?"
690
801
 
691
802
  try:
@@ -731,7 +842,7 @@ class DynamoDBPartiQLBackend(CursorBackend):
731
842
  if "N" in attribute_value:
732
843
  try:
733
844
  return Decimal(attribute_value["N"])
734
- except DecimalException:
845
+ except InvalidOperation: # Changed from DecimalException
735
846
  logger.warning(
736
847
  f"Could not convert N value '{attribute_value['N']}' to Decimal."
737
848
  )
@@ -741,7 +852,13 @@ class DynamoDBPartiQLBackend(CursorBackend):
741
852
  if "NULL" in attribute_value:
742
853
  return None
743
854
  if "B" in attribute_value:
744
- return base64.b64decode(attribute_value["B"])
855
+ try:
856
+ return base64.b64decode(attribute_value["B"])
857
+ except (binascii.Error, TypeError) as e:
858
+ logger.warning(
859
+ f"Failed to decode base64 binary value: {attribute_value['B']}, error: {e}"
860
+ )
861
+ return attribute_value["B"] # Return raw if decoding fails
745
862
  if "L" in attribute_value:
746
863
  return [self._map_from_boto3_value(item) for item in attribute_value["L"]]
747
864
  if "M" in attribute_value:
@@ -754,13 +871,19 @@ class DynamoDBPartiQLBackend(CursorBackend):
754
871
  if "NS" in attribute_value:
755
872
  try:
756
873
  return set(Decimal(n_val) for n_val in attribute_value["NS"])
757
- except DecimalException:
874
+ except InvalidOperation: # Changed from DecimalException
758
875
  logger.warning(
759
876
  f"Could not convert one or more NS values in '{attribute_value['NS']}' to Decimal."
760
877
  )
761
878
  return set(attribute_value["NS"])
762
879
  if "BS" in attribute_value:
763
- return set(base64.b64decode(b_val) for b_val in attribute_value["BS"])
880
+ try:
881
+ return set(base64.b64decode(b_val) for b_val in attribute_value["BS"])
882
+ except (binascii.Error, TypeError) as e:
883
+ logger.warning(
884
+ f"Failed to decode one or more base64 binary values in '{attribute_value['BS']}', error: {e}"
885
+ )
886
+ return set(attribute_value["BS"]) # Return raw if decoding fails
764
887
 
765
888
  logger.warning(f"Unrecognized DynamoDB attribute type: {attribute_value}")
766
889
  return attribute_value
@@ -29,7 +29,7 @@ class TestDynamoDBPartiQLBackend(unittest.TestCase):
29
29
 
30
30
  self.backend = DynamoDBPartiQLBackend(self.cursor_under_test)
31
31
  self.mock_model = MagicMock(spec=Model)
32
- self.mock_model.table_name = "my_test_table"
32
+ self.mock_model.get_table_name = MagicMock(return_value="my_test_table")
33
33
  self.mock_model.id_column_name = "id"
34
34
 
35
35
  self.mock_model.schema = MagicMock()
@@ -294,7 +294,9 @@ class TestDynamoDBPartiQLBackend(unittest.TestCase):
294
294
 
295
295
  results = list(self.backend.records(config, self.mock_model))
296
296
 
297
- expected_call_kwargs = {"Statement": expected_statement, "Parameters": []}
297
+ expected_call_kwargs = {
298
+ "Statement": expected_statement,
299
+ }
298
300
  self.mock_dynamodb_client.execute_statement.assert_called_once_with(
299
301
  **expected_call_kwargs
300
302
  )
@@ -324,7 +326,6 @@ class TestDynamoDBPartiQLBackend(unittest.TestCase):
324
326
 
325
327
  expected_call_kwargs = {
326
328
  "Statement": expected_statement,
327
- "Parameters": [],
328
329
  "Limit": 1,
329
330
  }
330
331
  self.mock_dynamodb_client.execute_statement.assert_called_once_with(
@@ -366,7 +367,6 @@ class TestDynamoDBPartiQLBackend(unittest.TestCase):
366
367
 
367
368
  expected_call_kwargs1 = {
368
369
  "Statement": expected_statement,
369
- "Parameters": [],
370
370
  "NextToken": initial_ddb_token,
371
371
  }
372
372
  self.mock_dynamodb_client.execute_statement.assert_called_once_with(
@@ -397,7 +397,6 @@ class TestDynamoDBPartiQLBackend(unittest.TestCase):
397
397
  )
398
398
  expected_call_kwargs2 = {
399
399
  "Statement": expected_statement,
400
- "Parameters": [],
401
400
  "NextToken": ddb_next_token_page1,
402
401
  }
403
402
  self.mock_dynamodb_client.execute_statement.assert_called_once_with(
@@ -420,7 +419,7 @@ class TestDynamoDBPartiQLBackend(unittest.TestCase):
420
419
  next_page_data = {}
421
420
  results = list(self.backend.records(config, self.mock_model, next_page_data))
422
421
 
423
- expected_call_kwargs = {"Statement": expected_statement, "Parameters": []}
422
+ expected_call_kwargs = {"Statement": expected_statement}
424
423
  self.mock_dynamodb_client.execute_statement.assert_called_once_with(
425
424
  **expected_call_kwargs
426
425
  )
@@ -451,7 +450,6 @@ class TestDynamoDBPartiQLBackend(unittest.TestCase):
451
450
 
452
451
  expected_call_kwargs = {
453
452
  "Statement": expected_statement,
454
- "Parameters": [],
455
453
  "Limit": 1,
456
454
  }
457
455
  self.mock_dynamodb_client.execute_statement.assert_called_once_with(
@@ -470,8 +468,14 @@ class TestDynamoDBPartiQLBackend(unittest.TestCase):
470
468
  def test_create_record(self, mock_logger_arg):
471
469
  """Test create() inserts a record and returns the input data."""
472
470
  data_to_create = {"id": "new_user_123", "name": "Jane Doe", "age": 28}
471
+ # Updated expected statement and parameters to match the new PartiQL format
472
+ expected_statement = (
473
+ "INSERT INTO \"my_test_table\" VALUE {'id': ?, 'name': ?, 'age': ?}"
474
+ )
473
475
  expected_ddb_parameters = [
474
- {"id": {"S": "new_user_123"}, "name": {"S": "Jane Doe"}, "age": {"N": "28"}}
476
+ {"S": "new_user_123"},
477
+ {"S": "Jane Doe"},
478
+ {"N": "28"},
475
479
  ]
476
480
 
477
481
  self.mock_dynamodb_client.execute_statement.return_value = {}
@@ -480,7 +484,7 @@ class TestDynamoDBPartiQLBackend(unittest.TestCase):
480
484
 
481
485
  self.assertEqual(created_data, data_to_create)
482
486
  self.mock_dynamodb_client.execute_statement.assert_called_once_with(
483
- Statement='INSERT INTO "my_test_table" VALUE ?',
487
+ Statement=expected_statement,
484
488
  Parameters=expected_ddb_parameters,
485
489
  )
486
490