altcodepro-polydb-python 2.5.20__tar.gz → 2.5.22__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.
- {altcodepro_polydb_python-2.5.20/src/altcodepro_polydb_python.egg-info → altcodepro_polydb_python-2.5.22}/PKG-INFO +1 -1
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/pyproject.toml +1 -1
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22/src/altcodepro_polydb_python.egg-info}/PKG-INFO +1 -1
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/altcodepro_polydb_python.egg-info/SOURCES.txt +4 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/__init__.py +1 -1
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/adapters/AzureKeyVaultAdapter.py +16 -2
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/adapters/AzureTableStorageAdapter.py +88 -6
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/base/NoSQLKVAdapter.py +37 -9
- altcodepro_polydb_python-2.5.22/src/polydb/base/constraints.py +260 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/databaseFactory.py +2 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/types.py +3 -0
- altcodepro_polydb_python-2.5.22/tests/test_azure_keyvault_credential.py +34 -0
- altcodepro_polydb_python-2.5.22/tests/test_azure_table_self_heal.py +64 -0
- altcodepro_polydb_python-2.5.22/tests/test_nosql_constraints.py +212 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/LICENSE +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/MANIFEST.in +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/README.md +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/requirements-aws.txt +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/requirements-azure.txt +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/requirements-dev.txt +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/requirements-gcp.txt +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/requirements-generic.txt +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/requirements.txt +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/setup.cfg +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/setup.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/altcodepro_polydb_python.egg-info/dependency_links.txt +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/altcodepro_polydb_python.egg-info/requires.txt +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/altcodepro_polydb_python.egg-info/top_level.txt +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/PolyDB.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/adapters/AWSSecretsManagerAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/adapters/AzureBlobStorageAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/adapters/AzureFileStorageAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/adapters/AzureQueueAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/adapters/BlockchainBlobAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/adapters/BlockchainFileAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/adapters/BlockchainKVAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/adapters/BlockchainQueueAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/adapters/DynamoDBAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/adapters/EFSAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/adapters/FirestoreAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/adapters/GCPFilestoreAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/adapters/GCPPubSubAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/adapters/GCPSecretManagerAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/adapters/GCPStorageAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/adapters/KafkaQueueAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/adapters/MongoDBAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/adapters/PostgreSQLAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/adapters/RabbitMQAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/adapters/S3Adapter.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/adapters/S3CompatibleAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/adapters/SQSAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/adapters/VaultAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/adapters/VercelBlobAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/adapters/VercelFileAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/adapters/VercelKVAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/adapters/VercelQueueAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/adapters/__init__.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/advanced_query.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/aio.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/audit/AuditStorage.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/audit/__init__.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/audit/context.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/audit/manager.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/audit/models.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/base/ObjectStorageAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/base/QueueAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/base/SecretsAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/base/SharedFilesAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/base/__init__.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/batch.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/cache.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/cloudDatabaseFactory.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/errors.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/json_safe.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/models.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/monitoring.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/multitenancy.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/observability/__init__.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/observability/logging.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/overflow_gc.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/py.typed +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/query.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/registry.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/retry.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/schema.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/security.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/services/__init__.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/services/compliance_service.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/services/security_service.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/utils.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/validation.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_advanced_query_injection.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_async_api.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_atomic_decrement.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_audit_chain_cutover.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_audit_hmac.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_aws.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_azure.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_azure_queue_receipt_handle.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_azure_table_empty_filter.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_azure_table_id_property_persisted.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_azure_table_id_return_value_sanitization_mismatch.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_azure_table_large_record_patch_roundtrip.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_azure_table_unpack_entity.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_blockchain.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_blockchain_overflow_guard.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_cloud_factory.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_factory_wiring.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_field_encryption.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_gcp.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_kafka.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_like_pattern_escaping.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_mongodb.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_monitoring_health.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_multi_engine.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_multitenancy_ddl_injection.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_nosql_kv_overflow_write_path.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_nosql_operator_injection.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_observability.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_overflow_gc.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_postgresql.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_postgresql_deserialize_row.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_queue_extend_delay_cancel.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_rabbitmq.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_read_projection.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_redis_kv.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_schema_ddl_injection.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_secrets_aws.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_secrets_vault.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/tests/test_session_vars.py +0 -0
- {altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/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.
|
|
3
|
+
Version: 2.5.22
|
|
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.
|
|
7
|
+
version = "2.5.22"
|
|
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.
|
|
3
|
+
Version: 2.5.22
|
|
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
|
|
@@ -90,11 +91,13 @@ tests/test_audit_chain_cutover.py
|
|
|
90
91
|
tests/test_audit_hmac.py
|
|
91
92
|
tests/test_aws.py
|
|
92
93
|
tests/test_azure.py
|
|
94
|
+
tests/test_azure_keyvault_credential.py
|
|
93
95
|
tests/test_azure_queue_receipt_handle.py
|
|
94
96
|
tests/test_azure_table_empty_filter.py
|
|
95
97
|
tests/test_azure_table_id_property_persisted.py
|
|
96
98
|
tests/test_azure_table_id_return_value_sanitization_mismatch.py
|
|
97
99
|
tests/test_azure_table_large_record_patch_roundtrip.py
|
|
100
|
+
tests/test_azure_table_self_heal.py
|
|
98
101
|
tests/test_azure_table_unpack_entity.py
|
|
99
102
|
tests/test_blockchain.py
|
|
100
103
|
tests/test_blockchain_overflow_guard.py
|
|
@@ -108,6 +111,7 @@ tests/test_mongodb.py
|
|
|
108
111
|
tests/test_monitoring_health.py
|
|
109
112
|
tests/test_multi_engine.py
|
|
110
113
|
tests/test_multitenancy_ddl_injection.py
|
|
114
|
+
tests/test_nosql_constraints.py
|
|
111
115
|
tests/test_nosql_kv_overflow_write_path.py
|
|
112
116
|
tests/test_nosql_operator_injection.py
|
|
113
117
|
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.
|
|
7
|
+
__version__ = "2.5.22"
|
|
8
8
|
|
|
9
9
|
from .aio import AsyncDatabaseFactory, AsyncPolyDB
|
|
10
10
|
from .audit.context import AuditContext
|
|
@@ -19,10 +19,24 @@ class AzureKeyVaultAdapter(SecretsAdapter):
|
|
|
19
19
|
self._initialize_client()
|
|
20
20
|
|
|
21
21
|
def _initialize_client(self) -> None:
|
|
22
|
-
from azure.identity import DefaultAzureCredential
|
|
23
22
|
from azure.keyvault.secrets import SecretClient
|
|
24
23
|
|
|
25
|
-
self._client = SecretClient(vault_url=self.vault_url, credential=
|
|
24
|
+
self._client = SecretClient(vault_url=self.vault_url, credential=self._build_credential())
|
|
25
|
+
|
|
26
|
+
@staticmethod
|
|
27
|
+
def _build_credential():
|
|
28
|
+
"""Inside Azure Container Apps / App Service / Functions a managed identity is available
|
|
29
|
+
(IDENTITY_ENDPOINT / MSI_ENDPOINT). DefaultAzureCredential tries EnvironmentCredential FIRST, and a deployment that
|
|
30
|
+
also exports AZURE_CLIENT_ID + AZURE_CLIENT_SECRET (for some other purpose) then fails with AADSTS7000232
|
|
31
|
+
"MSI identity should not use ClientSecretCredential". So when a managed identity exists it goes first
|
|
32
|
+
(AZURE_CLIENT_ID, if set, names a user-assigned identity); the normal chain stays as the fallback, so local
|
|
33
|
+
development (az login, environment variables) behaves as before."""
|
|
34
|
+
from azure.identity import ChainedTokenCredential, DefaultAzureCredential, ManagedIdentityCredential
|
|
35
|
+
|
|
36
|
+
if os.getenv("IDENTITY_ENDPOINT") or os.getenv("MSI_ENDPOINT"):
|
|
37
|
+
client_id = os.getenv("AZURE_CLIENT_ID") or None
|
|
38
|
+
return ChainedTokenCredential(ManagedIdentityCredential(client_id=client_id), DefaultAzureCredential())
|
|
39
|
+
return DefaultAzureCredential()
|
|
26
40
|
|
|
27
41
|
# Key Vault secret names may only contain alphanumerics and dashes --
|
|
28
42
|
# our secret keys (e.g. "tenant-42/stripe/api_key") use slashes, so
|
|
@@ -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"))
|
|
@@ -47,6 +51,59 @@ _RETRY_TOTAL = int(os.getenv("AZURE_TABLE_RETRY_TOTAL", "3"))
|
|
|
47
51
|
logging.getLogger("azure").setLevel(logging.ERROR)
|
|
48
52
|
|
|
49
53
|
|
|
54
|
+
|
|
55
|
+
def _is_table_missing(exc: BaseException) -> bool:
|
|
56
|
+
text = f"{type(exc).__name__} {exc}"
|
|
57
|
+
return "TableNotFound" in text or "The table specified does not exist" in text
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _SelfHealingTableClient:
|
|
61
|
+
"""Wraps an Azure TableClient so a table that disappears while the process is running (deleted by hand, a data
|
|
62
|
+
reset, a restore) is created again and the operation retried once, instead of failing with TableNotFound until
|
|
63
|
+
the process restarts. Lazy iterators (query_entities / list_entities) are healed only if the failure happens
|
|
64
|
+
before the first row was produced."""
|
|
65
|
+
|
|
66
|
+
_LAZY = ("query_entities", "list_entities")
|
|
67
|
+
|
|
68
|
+
def __init__(self, adapter: "AzureTableStorageAdapter", table_name: str, client: Any):
|
|
69
|
+
self._adapter = adapter
|
|
70
|
+
self._table_name = table_name
|
|
71
|
+
self._client = client
|
|
72
|
+
|
|
73
|
+
def _heal(self) -> None:
|
|
74
|
+
self._adapter._ensured_tables.discard(self._table_name)
|
|
75
|
+
self._adapter._client.create_table_if_not_exists(self._table_name)
|
|
76
|
+
self._adapter._ensured_tables.add(self._table_name)
|
|
77
|
+
logger.warning(f"Azure Table {self._table_name} was missing; created it again")
|
|
78
|
+
|
|
79
|
+
def __getattr__(self, name: str):
|
|
80
|
+
target = getattr(self._client, name)
|
|
81
|
+
if not callable(target):
|
|
82
|
+
return target
|
|
83
|
+
if name in self._LAZY:
|
|
84
|
+
def lazy(*args, **kwargs):
|
|
85
|
+
produced = False
|
|
86
|
+
try:
|
|
87
|
+
for item in target(*args, **kwargs):
|
|
88
|
+
produced = True
|
|
89
|
+
yield item
|
|
90
|
+
except Exception as exc: # noqa: BLE001
|
|
91
|
+
if produced or not _is_table_missing(exc):
|
|
92
|
+
raise
|
|
93
|
+
self._heal()
|
|
94
|
+
yield from getattr(self._client, name)(*args, **kwargs)
|
|
95
|
+
return lazy
|
|
96
|
+
|
|
97
|
+
def call(*args, **kwargs):
|
|
98
|
+
try:
|
|
99
|
+
return target(*args, **kwargs)
|
|
100
|
+
except Exception as exc: # noqa: BLE001
|
|
101
|
+
if not _is_table_missing(exc):
|
|
102
|
+
raise
|
|
103
|
+
self._heal()
|
|
104
|
+
return getattr(self._client, name)(*args, **kwargs)
|
|
105
|
+
return call
|
|
106
|
+
|
|
50
107
|
class AzureTableStorageAdapter(NoSQLKVAdapter):
|
|
51
108
|
"""
|
|
52
109
|
Azure Table Storage adapter with:
|
|
@@ -441,17 +498,21 @@ class AzureTableStorageAdapter(NoSQLKVAdapter):
|
|
|
441
498
|
try:
|
|
442
499
|
self._client.create_table_if_not_exists(table_name)
|
|
443
500
|
logger.info(f"✅ Azure Table ensured/created: {table_name}")
|
|
501
|
+
self._ensured_tables.add(table_name)
|
|
444
502
|
except Exception as e:
|
|
445
503
|
msg = str(e)
|
|
446
|
-
if "TableAlreadyExists"
|
|
447
|
-
|
|
448
|
-
|
|
504
|
+
if "TableAlreadyExists" in msg or "already exists" in msg.lower():
|
|
505
|
+
self._ensured_tables.add(table_name)
|
|
506
|
+
else:
|
|
507
|
+
# Do NOT remember a failed creation as "ensured": the next call tries again instead of
|
|
508
|
+
# failing with TableNotFound for the rest of the process's life.
|
|
509
|
+
logger.error(f"Could not create table {table_name}: {e}")
|
|
449
510
|
|
|
450
511
|
if cached is None:
|
|
451
512
|
cached = self._client.get_table_client(table_name=table_name)
|
|
452
513
|
self._table_clients_cache[table_name] = cached
|
|
453
514
|
|
|
454
|
-
return cached
|
|
515
|
+
return _SelfHealingTableClient(self, table_name, cached)
|
|
455
516
|
|
|
456
517
|
def _restore_overflow_properties(self, entity_dict: JsonDict) -> JsonDict:
|
|
457
518
|
restored = {}
|
|
@@ -500,7 +561,9 @@ class AzureTableStorageAdapter(NoSQLKVAdapter):
|
|
|
500
561
|
return restored
|
|
501
562
|
|
|
502
563
|
@retry(max_attempts=3, delay=1.0, exceptions=(NoSQLError,))
|
|
503
|
-
def _put_raw(
|
|
564
|
+
def _put_raw(
|
|
565
|
+
self, model: type, pk: str, rk: str, data: JsonDict, *, _insert_only: bool = False
|
|
566
|
+
) -> JsonDict:
|
|
504
567
|
try:
|
|
505
568
|
table_client = self._get_table_client(model)
|
|
506
569
|
safe_pk = self._sanitize_pk_rk(pk)
|
|
@@ -575,7 +638,10 @@ class AzureTableStorageAdapter(NoSQLKVAdapter):
|
|
|
575
638
|
else:
|
|
576
639
|
reference_entity[k] = v
|
|
577
640
|
|
|
578
|
-
|
|
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)
|
|
579
645
|
|
|
580
646
|
restored = self._unpack_entity(model, entity)
|
|
581
647
|
# Mirror the write-side condition above (line ~521): only
|
|
@@ -602,11 +668,27 @@ class AzureTableStorageAdapter(NoSQLKVAdapter):
|
|
|
602
668
|
return restored
|
|
603
669
|
|
|
604
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
|
|
605
675
|
if "PropertyValueTooLarge" in str(e):
|
|
606
676
|
raise NoSQLError("Azure Table limit exceeded → must use blob overflow") from e
|
|
607
677
|
|
|
608
678
|
raise NoSQLError(f"Azure Table put failed: {str(e)}")
|
|
609
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
|
+
|
|
610
692
|
@retry(max_attempts=3, delay=1.0, exceptions=(NoSQLError,))
|
|
611
693
|
def _get_raw(self, model: type, pk: str, rk: str) -> Optional[JsonDict]:
|
|
612
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
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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
|
|
326
|
-
|
|
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
|
-
|
|
329
|
-
|
|
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
|
-
|
|
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
|
{altcodepro_polydb_python-2.5.20 → altcodepro_polydb_python-2.5.22}/src/polydb/databaseFactory.py
RENAMED
|
@@ -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
|
|
|
@@ -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,34 @@
|
|
|
1
|
+
"""Inside a managed-identity host the managed identity is tried first, so a stray AZURE_CLIENT_SECRET cannot break Key Vault."""
|
|
2
|
+
import sys
|
|
3
|
+
import types
|
|
4
|
+
from unittest.mock import MagicMock
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture
|
|
10
|
+
def fake_identity(monkeypatch):
|
|
11
|
+
mod = types.ModuleType("azure.identity")
|
|
12
|
+
mod.ChainedTokenCredential = MagicMock(name="Chained")
|
|
13
|
+
mod.DefaultAzureCredential = MagicMock(name="Default")
|
|
14
|
+
mod.ManagedIdentityCredential = MagicMock(name="MI")
|
|
15
|
+
monkeypatch.setitem(sys.modules, "azure.identity", mod)
|
|
16
|
+
return mod
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_a_managed_identity_host_puts_the_managed_identity_first(fake_identity, monkeypatch):
|
|
20
|
+
from polydb.adapters.AzureKeyVaultAdapter import AzureKeyVaultAdapter
|
|
21
|
+
monkeypatch.setenv("IDENTITY_ENDPOINT", "http://169.254.0.1/msi")
|
|
22
|
+
monkeypatch.setenv("AZURE_CLIENT_ID", "mi-client-id")
|
|
23
|
+
AzureKeyVaultAdapter._build_credential()
|
|
24
|
+
fake_identity.ManagedIdentityCredential.assert_called_once_with(client_id="mi-client-id")
|
|
25
|
+
chain_args = fake_identity.ChainedTokenCredential.call_args.args
|
|
26
|
+
assert chain_args[0] is fake_identity.ManagedIdentityCredential.return_value and chain_args[1] is fake_identity.DefaultAzureCredential.return_value
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_elsewhere_the_default_chain_is_unchanged(fake_identity, monkeypatch):
|
|
30
|
+
from polydb.adapters.AzureKeyVaultAdapter import AzureKeyVaultAdapter
|
|
31
|
+
monkeypatch.delenv("IDENTITY_ENDPOINT", raising=False)
|
|
32
|
+
monkeypatch.delenv("MSI_ENDPOINT", raising=False)
|
|
33
|
+
assert AzureKeyVaultAdapter._build_credential() is fake_identity.DefaultAzureCredential.return_value
|
|
34
|
+
fake_identity.ManagedIdentityCredential.assert_not_called()
|