altcodepro-polydb-python 2.5.21__tar.gz → 2.5.23__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 (132) hide show
  1. {altcodepro_polydb_python-2.5.21/src/altcodepro_polydb_python.egg-info → altcodepro_polydb_python-2.5.23}/PKG-INFO +1 -1
  2. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/pyproject.toml +1 -1
  3. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23/src/altcodepro_polydb_python.egg-info}/PKG-INFO +1 -1
  4. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/altcodepro_polydb_python.egg-info/SOURCES.txt +3 -0
  5. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/__init__.py +1 -1
  6. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/adapters/AzureTableStorageAdapter.py +27 -2
  7. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/base/NoSQLKVAdapter.py +37 -9
  8. altcodepro_polydb_python-2.5.23/src/polydb/base/constraints.py +260 -0
  9. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/databaseFactory.py +9 -0
  10. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/types.py +3 -0
  11. altcodepro_polydb_python-2.5.23/tests/test_create_unique_violation.py +48 -0
  12. altcodepro_polydb_python-2.5.23/tests/test_nosql_constraints.py +212 -0
  13. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/LICENSE +0 -0
  14. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/MANIFEST.in +0 -0
  15. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/README.md +0 -0
  16. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/requirements-aws.txt +0 -0
  17. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/requirements-azure.txt +0 -0
  18. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/requirements-dev.txt +0 -0
  19. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/requirements-gcp.txt +0 -0
  20. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/requirements-generic.txt +0 -0
  21. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/requirements.txt +0 -0
  22. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/setup.cfg +0 -0
  23. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/setup.py +0 -0
  24. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/altcodepro_polydb_python.egg-info/dependency_links.txt +0 -0
  25. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/altcodepro_polydb_python.egg-info/requires.txt +0 -0
  26. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/altcodepro_polydb_python.egg-info/top_level.txt +0 -0
  27. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/PolyDB.py +0 -0
  28. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/adapters/AWSSecretsManagerAdapter.py +0 -0
  29. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/adapters/AzureBlobStorageAdapter.py +0 -0
  30. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/adapters/AzureFileStorageAdapter.py +0 -0
  31. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/adapters/AzureKeyVaultAdapter.py +0 -0
  32. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/adapters/AzureQueueAdapter.py +0 -0
  33. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/adapters/BlockchainBlobAdapter.py +0 -0
  34. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/adapters/BlockchainFileAdapter.py +0 -0
  35. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/adapters/BlockchainKVAdapter.py +0 -0
  36. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/adapters/BlockchainQueueAdapter.py +0 -0
  37. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/adapters/DynamoDBAdapter.py +0 -0
  38. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/adapters/EFSAdapter.py +0 -0
  39. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/adapters/FirestoreAdapter.py +0 -0
  40. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/adapters/GCPFilestoreAdapter.py +0 -0
  41. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/adapters/GCPPubSubAdapter.py +0 -0
  42. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/adapters/GCPSecretManagerAdapter.py +0 -0
  43. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/adapters/GCPStorageAdapter.py +0 -0
  44. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/adapters/KafkaQueueAdapter.py +0 -0
  45. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/adapters/MongoDBAdapter.py +0 -0
  46. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/adapters/PostgreSQLAdapter.py +0 -0
  47. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/adapters/RabbitMQAdapter.py +0 -0
  48. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/adapters/S3Adapter.py +0 -0
  49. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/adapters/S3CompatibleAdapter.py +0 -0
  50. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/adapters/SQSAdapter.py +0 -0
  51. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/adapters/VaultAdapter.py +0 -0
  52. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/adapters/VercelBlobAdapter.py +0 -0
  53. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/adapters/VercelFileAdapter.py +0 -0
  54. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/adapters/VercelKVAdapter.py +0 -0
  55. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/adapters/VercelQueueAdapter.py +0 -0
  56. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/adapters/__init__.py +0 -0
  57. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/advanced_query.py +0 -0
  58. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/aio.py +0 -0
  59. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/audit/AuditStorage.py +0 -0
  60. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/audit/__init__.py +0 -0
  61. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/audit/context.py +0 -0
  62. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/audit/manager.py +0 -0
  63. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/audit/models.py +0 -0
  64. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/base/ObjectStorageAdapter.py +0 -0
  65. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/base/QueueAdapter.py +0 -0
  66. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/base/SecretsAdapter.py +0 -0
  67. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/base/SharedFilesAdapter.py +0 -0
  68. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/base/__init__.py +0 -0
  69. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/batch.py +0 -0
  70. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/cache.py +0 -0
  71. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/cloudDatabaseFactory.py +0 -0
  72. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/errors.py +0 -0
  73. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/json_safe.py +0 -0
  74. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/models.py +0 -0
  75. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/monitoring.py +0 -0
  76. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/multitenancy.py +0 -0
  77. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/observability/__init__.py +0 -0
  78. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/observability/logging.py +0 -0
  79. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/overflow_gc.py +0 -0
  80. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/py.typed +0 -0
  81. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/query.py +0 -0
  82. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/registry.py +0 -0
  83. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/retry.py +0 -0
  84. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/schema.py +0 -0
  85. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/security.py +0 -0
  86. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/services/__init__.py +0 -0
  87. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/services/compliance_service.py +0 -0
  88. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/services/security_service.py +0 -0
  89. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/utils.py +0 -0
  90. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/src/polydb/validation.py +0 -0
  91. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_advanced_query_injection.py +0 -0
  92. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_async_api.py +0 -0
  93. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_atomic_decrement.py +0 -0
  94. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_audit_chain_cutover.py +0 -0
  95. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_audit_hmac.py +0 -0
  96. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_aws.py +0 -0
  97. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_azure.py +0 -0
  98. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_azure_keyvault_credential.py +0 -0
  99. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_azure_queue_receipt_handle.py +0 -0
  100. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_azure_table_empty_filter.py +0 -0
  101. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_azure_table_id_property_persisted.py +0 -0
  102. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_azure_table_id_return_value_sanitization_mismatch.py +0 -0
  103. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_azure_table_large_record_patch_roundtrip.py +0 -0
  104. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_azure_table_self_heal.py +0 -0
  105. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_azure_table_unpack_entity.py +0 -0
  106. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_blockchain.py +0 -0
  107. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_blockchain_overflow_guard.py +0 -0
  108. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_cloud_factory.py +0 -0
  109. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_factory_wiring.py +0 -0
  110. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_field_encryption.py +0 -0
  111. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_gcp.py +0 -0
  112. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_kafka.py +0 -0
  113. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_like_pattern_escaping.py +0 -0
  114. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_mongodb.py +0 -0
  115. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_monitoring_health.py +0 -0
  116. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_multi_engine.py +0 -0
  117. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_multitenancy_ddl_injection.py +0 -0
  118. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_nosql_kv_overflow_write_path.py +0 -0
  119. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_nosql_operator_injection.py +0 -0
  120. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_observability.py +0 -0
  121. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_overflow_gc.py +0 -0
  122. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_postgresql.py +0 -0
  123. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_postgresql_deserialize_row.py +0 -0
  124. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_queue_extend_delay_cancel.py +0 -0
  125. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_rabbitmq.py +0 -0
  126. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_read_projection.py +0 -0
  127. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_redis_kv.py +0 -0
  128. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_schema_ddl_injection.py +0 -0
  129. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_secrets_aws.py +0 -0
  130. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_secrets_vault.py +0 -0
  131. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_session_vars.py +0 -0
  132. {altcodepro_polydb_python-2.5.21 → altcodepro_polydb_python-2.5.23}/tests/test_vercel.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: altcodepro-polydb-python
3
- Version: 2.5.21
3
+ Version: 2.5.23
4
4
  Summary: Production-ready multi-cloud database abstraction layer with connection pooling, retry logic, and thread safety
5
5
  Author: AltCodePro
6
6
  Project-URL: Homepage, https://github.com/altcodepro/polydb-python
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "altcodepro-polydb-python"
7
- version = "2.5.21"
7
+ version = "2.5.23"
8
8
  description = "Production-ready multi-cloud database abstraction layer with connection pooling, retry logic, and thread safety"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: altcodepro-polydb-python
3
- Version: 2.5.21
3
+ Version: 2.5.23
4
4
  Summary: Production-ready multi-cloud database abstraction layer with connection pooling, retry logic, and thread safety
5
5
  Author: AltCodePro
6
6
  Project-URL: Homepage, https://github.com/altcodepro/polydb-python
@@ -78,6 +78,7 @@ src/polydb/base/QueueAdapter.py
78
78
  src/polydb/base/SecretsAdapter.py
79
79
  src/polydb/base/SharedFilesAdapter.py
80
80
  src/polydb/base/__init__.py
81
+ src/polydb/base/constraints.py
81
82
  src/polydb/observability/__init__.py
82
83
  src/polydb/observability/logging.py
83
84
  src/polydb/services/__init__.py
@@ -101,6 +102,7 @@ tests/test_azure_table_unpack_entity.py
101
102
  tests/test_blockchain.py
102
103
  tests/test_blockchain_overflow_guard.py
103
104
  tests/test_cloud_factory.py
105
+ tests/test_create_unique_violation.py
104
106
  tests/test_factory_wiring.py
105
107
  tests/test_field_encryption.py
106
108
  tests/test_gcp.py
@@ -110,6 +112,7 @@ tests/test_mongodb.py
110
112
  tests/test_monitoring_health.py
111
113
  tests/test_multi_engine.py
112
114
  tests/test_multitenancy_ddl_injection.py
115
+ tests/test_nosql_constraints.py
113
116
  tests/test_nosql_kv_overflow_write_path.py
114
117
  tests/test_nosql_operator_injection.py
115
118
  tests/test_observability.py
@@ -4,7 +4,7 @@ PolyDB - Enterprise Cloud-Independent Database Abstraction
4
4
  Full LINQ support, field-level audit, cache, soft delete, overflow storage
5
5
  """
6
6
 
7
- __version__ = "2.5.20"
7
+ __version__ = "2.5.23"
8
8
 
9
9
  from .aio import AsyncDatabaseFactory, AsyncPolyDB
10
10
  from .audit.context import AuditContext
@@ -40,6 +40,10 @@ _BASE64_RE = re.compile(r"^[A-Za-z0-9+/]*={0,2}$")
40
40
  # ensures model isolation across the same table
41
41
  _MODEL_FIELD = "__polydb_model__"
42
42
 
43
+
44
+ class _AlreadyExists(Exception):
45
+ """create_entity found the (PartitionKey, RowKey) taken. Deliberately NOT a NoSQLError so `@retry` does not retry it."""
46
+
43
47
  _CONNECT_TIMEOUT = float(os.getenv("AZURE_TABLE_CONNECT_TIMEOUT", "10"))
44
48
  _READ_TIMEOUT = float(os.getenv("AZURE_TABLE_READ_TIMEOUT", "30"))
45
49
  _RETRY_TOTAL = int(os.getenv("AZURE_TABLE_RETRY_TOTAL", "3"))
@@ -557,7 +561,9 @@ class AzureTableStorageAdapter(NoSQLKVAdapter):
557
561
  return restored
558
562
 
559
563
  @retry(max_attempts=3, delay=1.0, exceptions=(NoSQLError,))
560
- def _put_raw(self, model: type, pk: str, rk: str, data: JsonDict) -> JsonDict:
564
+ def _put_raw(
565
+ self, model: type, pk: str, rk: str, data: JsonDict, *, _insert_only: bool = False
566
+ ) -> JsonDict:
561
567
  try:
562
568
  table_client = self._get_table_client(model)
563
569
  safe_pk = self._sanitize_pk_rk(pk)
@@ -632,7 +638,10 @@ class AzureTableStorageAdapter(NoSQLKVAdapter):
632
638
  else:
633
639
  reference_entity[k] = v
634
640
 
635
- table_client.upsert_entity(reference_entity)
641
+ if _insert_only:
642
+ table_client.create_entity(reference_entity) # 409 when the key is taken: atomic claim
643
+ else:
644
+ table_client.upsert_entity(reference_entity)
636
645
 
637
646
  restored = self._unpack_entity(model, entity)
638
647
  # Mirror the write-side condition above (line ~521): only
@@ -659,11 +668,27 @@ class AzureTableStorageAdapter(NoSQLKVAdapter):
659
668
  return restored
660
669
 
661
670
  except Exception as e:
671
+ if _insert_only and (
672
+ type(e).__name__ == "ResourceExistsError" or "EntityAlreadyExists" in str(e)
673
+ ):
674
+ raise _AlreadyExists() from e
662
675
  if "PropertyValueTooLarge" in str(e):
663
676
  raise NoSQLError("Azure Table limit exceeded → must use blob overflow") from e
664
677
 
665
678
  raise NoSQLError(f"Azure Table put failed: {str(e)}")
666
679
 
680
+ def _put_new_raw(self, model: type, pk: str, rk: str, data: JsonDict) -> bool:
681
+ """Atomic insert-if-absent via Table Storage's `create_entity` (HTTP 409 when the key exists)."""
682
+ try:
683
+ self._put_raw(model, pk, rk, data, _insert_only=True)
684
+ return True
685
+ except _AlreadyExists:
686
+ return False
687
+
688
+ def _query_partition_raw(self, model: type, partition: str, limit: Optional[int]) -> List[JsonDict]:
689
+ """One partition -- a PartitionKey filter, not a table scan."""
690
+ return self._query_raw(model, {"partition_key": partition}, limit)
691
+
667
692
  @retry(max_attempts=3, delay=1.0, exceptions=(NoSQLError,))
668
693
  def _get_raw(self, model: type, pk: str, rk: str) -> Optional[JsonDict]:
669
694
  try:
@@ -12,6 +12,7 @@ from ..json_safe import json_safe
12
12
  from ..query import Operator, QueryBuilder
13
13
  from ..retry import retry
14
14
  from ..types import JsonDict, Lookup
15
+ from .constraints import NoSQLConstraintsMixin, UniqueConstraintError # noqa: F401 (re-exported)
15
16
 
16
17
  if TYPE_CHECKING:
17
18
  from ..models import PartitionConfig
@@ -46,7 +47,7 @@ def select_columns(
46
47
  return None
47
48
 
48
49
 
49
- class NoSQLKVAdapter:
50
+ class NoSQLKVAdapter(NoSQLConstraintsMixin):
50
51
  """Base with auto-overflow and LINQ support"""
51
52
 
52
53
  # An adapter sets this True when its `_query_raw` accepts `select=[property names]` and really sends it to the store (Azure
@@ -266,8 +267,16 @@ class NoSQLKVAdapter:
266
267
  # Protocol implementation
267
268
  def put(self, model: type, data: JsonDict) -> JsonDict:
268
269
  pk, rk = self._get_pk_rk(model, data)
269
- store_data, _ = self._check_overflow(data)
270
- return self._put_raw(model, pk, rk, store_data)
270
+
271
+ def _write() -> JsonDict:
272
+ store_data, _ = self._check_overflow(data)
273
+ return self._put_raw(model, pk, rk, store_data)
274
+
275
+ uq, ix = self._constraint_fields(model)
276
+ if not (uq or ix):
277
+ return _write()
278
+ previous = self._get_raw(model, pk, rk)
279
+ return self._constrained_write(model, pk, rk, previous, {**(previous or {}), **data}, _write)
271
280
 
272
281
  def query(
273
282
  self,
@@ -279,6 +288,9 @@ class NoSQLKVAdapter:
279
288
  fields: Optional[List[str]] = None,
280
289
  omit: Optional[List[str]] = None,
281
290
  ) -> List[JsonDict]:
291
+ indexed = self._indexed_query(model, query or {}, limit)
292
+ if indexed is not None:
293
+ return project_rows([self._retrieve_overflow(r) for r in indexed], fields, omit)
282
294
  select = select_columns(fields, omit, getattr(model, "__polydb__", {}).get("columns"))
283
295
  if select is not None and self.SUPPORTS_SELECT_PUSHDOWN:
284
296
  results = self._query_raw(model, query or {}, limit, select=select)
@@ -318,15 +330,25 @@ class NoSQLKVAdapter:
318
330
  else:
319
331
  pk, rk = self._get_pk_rk(model, {"id": entity_id, **data})
320
332
 
321
- if not replace:
333
+ uq, ix = self._constraint_fields(model)
334
+ constrained = bool(uq or ix) and pk is not None
335
+ previous: Optional[JsonDict] = None
336
+ if not replace or constrained:
322
337
  existing = self._get_raw(model, pk, rk) # type: ignore
323
338
  if existing:
324
339
  existing = self._retrieve_overflow(existing)
325
- existing.update(data)
326
- data = existing
340
+ previous = dict(existing)
341
+ if not replace:
342
+ existing.update(data)
343
+ data = existing
344
+
345
+ def _write() -> JsonDict:
346
+ store_data, _ = self._check_overflow(data)
347
+ return self._put_raw(model, pk, rk, store_data) # type: ignore
327
348
 
328
- store_data, _ = self._check_overflow(data)
329
- return self._put_raw(model, pk, rk, store_data) # type: ignore
349
+ if not constrained:
350
+ return _write()
351
+ return self._constrained_write(model, pk, rk, previous, data, _write) # type: ignore[arg-type]
330
352
 
331
353
  def upsert(self, model: type, data: JsonDict, *, replace: bool = False) -> JsonDict:
332
354
  return self.put(model, data)
@@ -340,7 +362,13 @@ class NoSQLKVAdapter:
340
362
  else:
341
363
  pk, rk = self._get_pk_rk(model, {"id": entity_id})
342
364
 
343
- return self._delete_raw(model, pk, rk, etag) # type: ignore
365
+ uq, ix = self._constraint_fields(model)
366
+ if not (uq or ix) or pk is None:
367
+ return self._delete_raw(model, pk, rk, etag) # type: ignore
368
+ existing = self._get_raw(model, pk, rk) # type: ignore
369
+ result = self._delete_raw(model, pk, rk, etag) # type: ignore
370
+ self._release_all(model, pk, rk, existing)
371
+ return result
344
372
 
345
373
  def query_linq_rows(self, model: type, builder: QueryBuilder) -> List[JsonDict]:
346
374
  """
@@ -0,0 +1,260 @@
1
+ # src/polydb/base/constraints.py
2
+ """
3
+ Unique constraints and equality lookup indexes for key/value NoSQL stores.
4
+
5
+ Table Storage (and every other KV backend here) enforces uniqueness only for the (PartitionKey, RowKey) pair and can only be
6
+ queried efficiently by those two keys. A model that declares `unique_fields` / `index_fields` in `__polydb__` gets both from this
7
+ mixin, on every NoSQL adapter, with no per-provider code beyond one primitive (`_put_new_raw`, insert-if-absent).
8
+
9
+ How it works -- two hidden side tables per model (`<table>uq`, `<table>ix`):
10
+
11
+ * UNIQUE one "claim" row per (field, value): PartitionKey = u<2 hex of the hash>, RowKey = sha256(field, value). The claim is
12
+ written with an INSERT-IF-ABSENT before the entity, so two writers racing for one value cannot both succeed. A
13
+ duplicate raises UniqueConstraintError. The claim names its owner entity; a claim whose owner no longer exists, or
14
+ whose owner moved to another value (a crash between the claim and the entity write), is taken over.
15
+ * INDEX one row per (field, value, entity): PartitionKey = i<hash of (field, value)>, RowKey = hash of the entity key. An
16
+ equality query on an indexed field reads that one partition, then point-reads each owner and re-checks every filter,
17
+ so a stale index row can never produce a wrong result (it is skipped).
18
+
19
+ Ordering: claims and index rows are written BEFORE the entity and removed AFTER a delete / value change, so a visible entity
20
+ always has its rows. The residue of a crash is a stale row, which is harmless (verified on read, taken over on the next claim).
21
+
22
+ Not covered: rows written before a field was declared have no claim/index row -- run `rebuild_constraints()` once. Backends
23
+ without a native conditional insert fall back to a non-atomic get-then-put (documented on `_put_new_raw`).
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import hashlib
29
+ import re
30
+ from typing import Any, Dict, List, Optional, Tuple
31
+
32
+ from ..errors import NoSQLError
33
+ from ..types import JsonDict
34
+
35
+ _UNSAFE_KEY = re.compile(r"[\\/#\?\x00-\x1f\x7f:+ ]")
36
+ _UQ, _IX = "uq", "ix"
37
+
38
+
39
+ class UniqueConstraintError(NoSQLError):
40
+ """A write would create a second row with a value a `unique_fields` field already holds."""
41
+
42
+ def __init__(self, field: str, value: Any):
43
+ super().__init__(f"unique constraint violated on {field!r}")
44
+ self.field = field
45
+ self.value = value
46
+
47
+
48
+ def normalize_value(value: Any) -> Optional[str]:
49
+ """The string two values are compared as. None/empty/containers carry no constraint."""
50
+ if value is None or isinstance(value, (dict, list, tuple, set)):
51
+ return None
52
+ if isinstance(value, bool):
53
+ return "true" if value else "false"
54
+ text = str(value)
55
+ return text if text != "" else None
56
+
57
+
58
+ def _digest(*parts: str) -> str:
59
+ return hashlib.sha256("\x1f".join(parts).encode("utf-8")).hexdigest()
60
+
61
+
62
+ class NoSQLConstraintsMixin:
63
+ """Mixed into NoSQLKVAdapter. Needs `_get_raw`, `_put_raw`, `_delete_raw`, `_query_raw`, `_get_pk_rk`."""
64
+
65
+ # -- metadata ---------------------------------------------------------------------------------------------------------
66
+
67
+ @staticmethod
68
+ def _constraint_fields(model: type) -> Tuple[Tuple[str, ...], Tuple[str, ...]]:
69
+ meta = getattr(model, "__polydb__", None) or {}
70
+ return tuple(meta.get("unique_fields") or ()), tuple(meta.get("index_fields") or ())
71
+
72
+ def _side_model(self, model: type, suffix: str) -> type:
73
+ cache = self.__dict__.setdefault("_side_models", {})
74
+ meta = getattr(model, "__polydb__", None) or {}
75
+ base = meta.get("collection") or meta.get("collection_name") or meta.get("table") or getattr(model, "__name__", "model")
76
+ base = re.sub(r"[^A-Za-z0-9]", "", str(base)).lower()[:58] or "model"
77
+ key = (base, suffix)
78
+ if key not in cache:
79
+ name = f"{base}{suffix}"
80
+ cache[key] = type(name, (), {"__polydb__": {"storage": "nosql", "table": name, "pk_field": "bucket", "rk_field": "id"}})
81
+ return cache[key]
82
+
83
+ # -- insert-if-absent primitive ----------------------------------------------------------------------------------------
84
+
85
+ def _put_new_raw(self, model: type, pk: str, rk: str, data: JsonDict) -> bool:
86
+ """Insert only when (pk, rk) is absent. True = inserted, False = already there. This default is get-then-put and is NOT
87
+ atomic; an adapter with a native conditional create (Azure `create_entity`, Dynamo `attribute_not_exists`, ...) overrides."""
88
+ if self._get_raw(model, pk, rk) is not None: # type: ignore[attr-defined]
89
+ return False
90
+ self._put_raw(model, pk, rk, data) # type: ignore[attr-defined]
91
+ return True
92
+
93
+ def _query_partition_raw(self, model: type, partition: str, limit: Optional[int]) -> List[JsonDict]:
94
+ """Every row of one partition. Portable default: a property equality on the mirrored `ix_bucket` field."""
95
+ return self._query_raw(model, {"ix_bucket": partition}, limit) # type: ignore[attr-defined]
96
+
97
+ # -- side-row helpers --------------------------------------------------------------------------------------------------
98
+
99
+ def _safe_delete(self, model: type, pk: str, rk: str) -> None:
100
+ try:
101
+ self._delete_raw(model, pk, rk, None) # type: ignore[attr-defined]
102
+ except Exception: # already gone, or the row was never written
103
+ pass
104
+
105
+ def _claim(self, model: type, pk: str, rk: str, field: str, value: str) -> None:
106
+ side = self._side_model(model, _UQ)
107
+ h = _digest(field, value)
108
+ spk = f"u{h[:2]}"
109
+ row = {"bucket": spk, "ix_bucket": spk, "id": h, "field": field, "owner_pk": pk, "owner_rk": rk}
110
+ for _ in range(4):
111
+ if self._put_new_raw(side, spk, h, row):
112
+ return
113
+ cur = self._get_raw(side, spk, h) # type: ignore[attr-defined]
114
+ if cur is None: # released between our insert and our read
115
+ continue
116
+ if (cur.get("owner_pk"), cur.get("owner_rk")) == (pk, rk):
117
+ return # our own claim (an idempotent retry)
118
+ owner = self._get_raw(model, cur.get("owner_pk"), cur.get("owner_rk")) # type: ignore[attr-defined]
119
+ if owner is not None and normalize_value(owner.get(field)) == value:
120
+ raise UniqueConstraintError(field, value)
121
+ self._safe_delete(side, spk, h) # orphaned or moved-on claim: take it over
122
+ raise UniqueConstraintError(field, value)
123
+
124
+ def _release(self, model: type, pk: str, rk: str, field: str, value: str) -> None:
125
+ side = self._side_model(model, _UQ)
126
+ h = _digest(field, value)
127
+ spk = f"u{h[:2]}"
128
+ cur = self._get_raw(side, spk, h) # type: ignore[attr-defined]
129
+ if cur is not None and (cur.get("owner_pk"), cur.get("owner_rk")) == (pk, rk):
130
+ self._safe_delete(side, spk, h)
131
+
132
+ def _index_key(self, field: str, value: str, pk: str, rk: str) -> Tuple[str, str]:
133
+ return f"i{_digest(field, value)}", _digest(str(pk), str(rk))
134
+
135
+ def _index_put(self, model: type, pk: str, rk: str, field: str, value: str) -> None:
136
+ side = self._side_model(model, _IX)
137
+ spk, srk = self._index_key(field, value, pk, rk)
138
+ row = {"bucket": spk, "ix_bucket": spk, "id": srk, "field": field, "owner_pk": pk, "owner_rk": rk}
139
+ self._put_raw(side, spk, srk, row) # type: ignore[attr-defined]
140
+
141
+ def _index_del(self, model: type, pk: str, rk: str, field: str, value: str) -> None:
142
+ spk, srk = self._index_key(field, value, pk, rk)
143
+ self._safe_delete(self._side_model(model, _IX), spk, srk)
144
+
145
+ # -- write path --------------------------------------------------------------------------------------------------------
146
+
147
+ def _constrained_write(self, model: type, pk: str, rk: str, previous: Optional[JsonDict], new: JsonDict, write):
148
+ """Claim -> index -> entity write -> release/remove the old rows. `new` is the row as it will exist afterwards."""
149
+ uq, ix = self._constraint_fields(model)
150
+ prev = previous or {}
151
+ claimed: List[Tuple[str, str]] = []
152
+ try:
153
+ for f in uq:
154
+ nv, ov = normalize_value(new.get(f)), normalize_value(prev.get(f))
155
+ if nv is not None and nv != ov:
156
+ self._claim(model, pk, rk, f, nv)
157
+ claimed.append((f, nv))
158
+ for f in ix:
159
+ nv, ov = normalize_value(new.get(f)), normalize_value(prev.get(f))
160
+ if nv is not None and nv != ov:
161
+ self._index_put(model, pk, rk, f, nv)
162
+ result = write()
163
+ except Exception:
164
+ for f, v in claimed:
165
+ self._release(model, pk, rk, f, v)
166
+ raise
167
+ for f in uq:
168
+ nv, ov = normalize_value(new.get(f)), normalize_value(prev.get(f))
169
+ if ov is not None and ov != nv:
170
+ self._release(model, pk, rk, f, ov)
171
+ for f in ix:
172
+ nv, ov = normalize_value(new.get(f)), normalize_value(prev.get(f))
173
+ if ov is not None and ov != nv:
174
+ self._index_del(model, pk, rk, f, ov)
175
+ return result
176
+
177
+ def _release_all(self, model: type, pk: str, rk: str, row: Optional[JsonDict]) -> None:
178
+ if not row:
179
+ return
180
+ uq, ix = self._constraint_fields(model)
181
+ for f in uq:
182
+ v = normalize_value(row.get(f))
183
+ if v is not None:
184
+ self._release(model, pk, rk, f, v)
185
+ for f in ix:
186
+ v = normalize_value(row.get(f))
187
+ if v is not None:
188
+ self._index_del(model, pk, rk, f, v)
189
+
190
+ # -- read path ---------------------------------------------------------------------------------------------------------
191
+
192
+ def _lookup_owners(self, model: type, field: str, value: str, limit: Optional[int]) -> List[Tuple[str, str]]:
193
+ uq, _ = self._constraint_fields(model)
194
+ h = _digest(field, value)
195
+ if field in uq:
196
+ cur = self._get_raw(self._side_model(model, _UQ), f"u{h[:2]}", h) # type: ignore[attr-defined]
197
+ return [(cur["owner_pk"], cur["owner_rk"])] if cur else []
198
+ rows = self._query_partition_raw(self._side_model(model, _IX), f"i{h}", limit)
199
+ return [(r["owner_pk"], r["owner_rk"]) for r in rows if r.get("owner_pk") is not None]
200
+
201
+ @staticmethod
202
+ def _filter_matches(row: JsonDict, query: Dict[str, Any], pk: str, rk: str) -> bool:
203
+ for k, want in query.items():
204
+ if k in ("partition_key", "PartitionKey"):
205
+ if _UNSAFE_KEY.sub("_", str(pk)) != str(want):
206
+ return False
207
+ elif k in ("row_key", "RowKey"):
208
+ if _UNSAFE_KEY.sub("_", str(rk)) != str(want):
209
+ return False
210
+ elif want is None:
211
+ if row.get(k) is not None:
212
+ return False
213
+ elif normalize_value(row.get(k)) != normalize_value(want):
214
+ return False
215
+ return True
216
+
217
+ def _indexed_query(self, model: type, query: Dict[str, Any], limit: Optional[int]) -> Optional[List[JsonDict]]:
218
+ """Rows for a query that has an equality on a unique/indexed field, or None when the index cannot serve it."""
219
+ uq, ix = self._constraint_fields(model)
220
+ if not query or not (uq or ix):
221
+ return None
222
+ if any(isinstance(v, (dict, list, tuple, set)) for v in query.values()):
223
+ return None
224
+ for f in (*uq, *ix):
225
+ nv = normalize_value(query.get(f)) if f in query else None
226
+ if nv is None:
227
+ continue
228
+ out: List[JsonDict] = []
229
+ for opk, ork in self._lookup_owners(model, f, nv, None):
230
+ row = self._get_raw(model, opk, ork) # type: ignore[attr-defined]
231
+ if row is not None and self._filter_matches(row, query, opk, ork):
232
+ out.append(row)
233
+ if limit and len(out) >= limit:
234
+ break
235
+ return out
236
+ return None
237
+
238
+ def rebuild_constraints(self, model: type) -> Dict[str, int]:
239
+ """One-off backfill for rows written before a field was declared unique/indexed. Claims every existing value (the
240
+ first holder keeps it; a duplicate is reported, not overwritten) and writes every index row."""
241
+ uq, ix = self._constraint_fields(model)
242
+ done = {"rows": 0, "claims": 0, "indexed": 0, "duplicates": 0}
243
+ for row in self._query_raw(model, {}, None): # type: ignore[attr-defined]
244
+ pk, rk = self._get_pk_rk(model, row) # type: ignore[attr-defined]
245
+ done["rows"] += 1
246
+ for f in uq:
247
+ v = normalize_value(row.get(f))
248
+ if v is None:
249
+ continue
250
+ try:
251
+ self._claim(model, pk, rk, f, v)
252
+ done["claims"] += 1
253
+ except UniqueConstraintError:
254
+ done["duplicates"] += 1
255
+ for f in ix:
256
+ v = normalize_value(row.get(f))
257
+ if v is not None:
258
+ self._index_put(model, pk, rk, f, v)
259
+ done["indexed"] += 1
260
+ return done
@@ -167,6 +167,8 @@ def _extract_meta(model: Union[type, str]) -> ModelMeta:
167
167
  cache=raw.get("cache", False),
168
168
  cache_ttl=raw.get("cache_ttl"),
169
169
  encrypted_fields=tuple(raw.get("encrypted_fields") or ()),
170
+ unique_fields=tuple(raw.get("unique_fields") or ()),
171
+ index_fields=tuple(raw.get("index_fields") or ()),
170
172
  )
171
173
  return ModelMeta(storage="nosql", table=None, collection=None)
172
174
 
@@ -455,6 +457,13 @@ class DatabaseFactory:
455
457
  conflict_cols = _parse_unique_violation_columns(exc)
456
458
  if not conflict_cols or not all(c in data for c in conflict_cols):
457
459
  raise
460
+ if not set(conflict_cols) <= {"id", "tenant_id"}:
461
+ # A declared-unique NON-key column: a second row with the same value is
462
+ # a violation, never a silent overwrite of the existing row.
463
+ from .base.constraints import UniqueConstraintError
464
+
465
+ col = conflict_cols[0]
466
+ raise UniqueConstraintError(col, data.get(col)) from exc
458
467
  where = {c: data[c] for c in conflict_cols}
459
468
  logger.warning(
460
469
  "insert %s hit unique violation on %s — falling through to update",
@@ -26,6 +26,9 @@ class ModelMeta:
26
26
  cache_ttl: Optional[int] = None
27
27
  # Fields stored encrypted when the factory has encryption enabled (a tuple: the dataclass is frozen/hashable).
28
28
  encrypted_fields: Tuple[str, ...] = ()
29
+ # NoSQL: fields that must stay unique across the model / that get an equality lookup index (see base/constraints.py).
30
+ unique_fields: Tuple[str, ...] = ()
31
+ index_fields: Tuple[str, ...] = ()
29
32
 
30
33
 
31
34
  @runtime_checkable
@@ -0,0 +1,48 @@
1
+ import pytest
2
+
3
+ from polydb.base.constraints import UniqueConstraintError
4
+ from polydb.databaseFactory import DatabaseFactory
5
+
6
+
7
+ class _Sql:
8
+ def __init__(self, msg):
9
+ self.msg, self.updated = msg, False
10
+
11
+ def insert(self, table, data, session_vars=None):
12
+ raise Exception(self.msg)
13
+
14
+ def update(self, *a, **k):
15
+ self.updated = True
16
+ return {"id": "1"}
17
+
18
+
19
+ class M:
20
+ __polydb__ = {"storage": "sql", "table": "users"}
21
+
22
+
23
+ def _factory(msg):
24
+ f = DatabaseFactory.__new__(DatabaseFactory)
25
+ sql = _Sql(msg)
26
+ f._inject_audit_fields = lambda d, is_create: d
27
+ f.encryption = None
28
+ f._enable_cache = False
29
+ f._cache = None
30
+ f.metrics = None
31
+ f._audit_write = lambda *a, **k: None
32
+ f._run = lambda fn: fn()
33
+ f._is_sql = lambda *a, **k: True
34
+ f._adapters_for = lambda *a, **k: type("A", (), {"sql": sql})()
35
+ return f, sql
36
+
37
+
38
+ def test_non_key_unique_violation_raises():
39
+ f, sql = _factory("duplicate key value violates unique constraint\nKey (email)=(a@b.c) already exists")
40
+ with pytest.raises(UniqueConstraintError):
41
+ f.create(M, {"email": "a@b.c", "id": "2"})
42
+ assert not sql.updated
43
+
44
+
45
+ def test_primary_key_conflict_still_overwrites():
46
+ f, sql = _factory("duplicate key value violates unique constraint\nKey (id, tenant_id)=(1, t) already exists")
47
+ f.create(M, {"id": "1", "tenant_id": "t", "x": 1})
48
+ assert sql.updated