altcodepro-polydb-python 2.5.19__tar.gz → 2.5.20__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.19/src/altcodepro_polydb_python.egg-info → altcodepro_polydb_python-2.5.20}/PKG-INFO +1 -1
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/pyproject.toml +1 -1
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20/src/altcodepro_polydb_python.egg-info}/PKG-INFO +1 -1
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/altcodepro_polydb_python.egg-info/SOURCES.txt +1 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/__init__.py +1 -1
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/DynamoDBAdapter.py +7 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/base/NoSQLKVAdapter.py +3 -1
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/databaseFactory.py +117 -20
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/schema.py +41 -4
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/types.py +2 -0
- altcodepro_polydb_python-2.5.20/tests/test_factory_wiring.py +247 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/LICENSE +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/MANIFEST.in +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/README.md +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/requirements-aws.txt +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/requirements-azure.txt +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/requirements-dev.txt +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/requirements-gcp.txt +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/requirements-generic.txt +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/requirements.txt +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/setup.cfg +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/setup.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/altcodepro_polydb_python.egg-info/dependency_links.txt +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/altcodepro_polydb_python.egg-info/requires.txt +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/altcodepro_polydb_python.egg-info/top_level.txt +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/PolyDB.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/AWSSecretsManagerAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/AzureBlobStorageAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/AzureFileStorageAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/AzureKeyVaultAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/AzureQueueAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/AzureTableStorageAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/BlockchainBlobAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/BlockchainFileAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/BlockchainKVAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/BlockchainQueueAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/EFSAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/FirestoreAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/GCPFilestoreAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/GCPPubSubAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/GCPSecretManagerAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/GCPStorageAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/KafkaQueueAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/MongoDBAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/PostgreSQLAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/RabbitMQAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/S3Adapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/S3CompatibleAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/SQSAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/VaultAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/VercelBlobAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/VercelFileAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/VercelKVAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/VercelQueueAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/__init__.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/advanced_query.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/aio.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/audit/AuditStorage.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/audit/__init__.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/audit/context.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/audit/manager.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/audit/models.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/base/ObjectStorageAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/base/QueueAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/base/SecretsAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/base/SharedFilesAdapter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/base/__init__.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/batch.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/cache.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/cloudDatabaseFactory.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/errors.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/json_safe.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/models.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/monitoring.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/multitenancy.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/observability/__init__.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/observability/logging.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/overflow_gc.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/py.typed +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/query.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/registry.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/retry.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/security.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/services/__init__.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/services/compliance_service.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/services/security_service.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/utils.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/validation.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_advanced_query_injection.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_async_api.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_atomic_decrement.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_audit_chain_cutover.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_audit_hmac.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_aws.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_azure.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_azure_queue_receipt_handle.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_azure_table_empty_filter.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_azure_table_id_property_persisted.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_azure_table_id_return_value_sanitization_mismatch.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_azure_table_large_record_patch_roundtrip.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_azure_table_unpack_entity.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_blockchain.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_blockchain_overflow_guard.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_cloud_factory.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_field_encryption.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_gcp.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_kafka.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_like_pattern_escaping.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_mongodb.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_monitoring_health.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_multi_engine.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_multitenancy_ddl_injection.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_nosql_kv_overflow_write_path.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_nosql_operator_injection.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_observability.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_overflow_gc.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_postgresql.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_postgresql_deserialize_row.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_queue_extend_delay_cancel.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_rabbitmq.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_read_projection.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_redis_kv.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_schema_ddl_injection.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_secrets_aws.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_secrets_vault.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_session_vars.py +0 -0
- {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/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.20
|
|
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.20"
|
|
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.20
|
|
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
|
|
@@ -99,6 +99,7 @@ tests/test_azure_table_unpack_entity.py
|
|
|
99
99
|
tests/test_blockchain.py
|
|
100
100
|
tests/test_blockchain_overflow_guard.py
|
|
101
101
|
tests/test_cloud_factory.py
|
|
102
|
+
tests/test_factory_wiring.py
|
|
102
103
|
tests/test_field_encryption.py
|
|
103
104
|
tests/test_gcp.py
|
|
104
105
|
tests/test_kafka.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.20"
|
|
8
8
|
|
|
9
9
|
from .aio import AsyncDatabaseFactory, AsyncPolyDB
|
|
10
10
|
from .audit.context import AuditContext
|
|
@@ -196,6 +196,13 @@ class DynamoDBAdapter(NoSQLKVAdapter):
|
|
|
196
196
|
def _blob_key(self, model: type, pk: str, rk: str, checksum: str) -> str:
|
|
197
197
|
return f"overflow/{self._table_name(model)}/{pk}/{rk}/{checksum}.json"
|
|
198
198
|
|
|
199
|
+
def _check_overflow(self, data: JsonDict): # type: ignore[override]
|
|
200
|
+
"""DynamoDB overflows inside `_put_raw` (`_maybe_overflow_to_s3`, into this adapter's own bucket), never in the
|
|
201
|
+
base class. The base whole-record overflow replaced the record with a stub pointing at a key in the separate
|
|
202
|
+
object store, which `_resolve_overflow` (it reads this adapter's bucket) can never find -- the record was lost
|
|
203
|
+
on read. Returning the data untouched leaves the one working path in charge."""
|
|
204
|
+
return data, None
|
|
205
|
+
|
|
199
206
|
def _maybe_overflow_to_s3(
|
|
200
207
|
self, model: type, pk: str, rk: str, payload: JsonDict
|
|
201
208
|
) -> Optional[JsonDict]:
|
|
@@ -291,7 +291,9 @@ class NoSQLKVAdapter:
|
|
|
291
291
|
) -> Tuple[List[JsonDict], Optional[str]]:
|
|
292
292
|
# Basic implementation - override per provider
|
|
293
293
|
offset = int(continuation_token) if continuation_token else 0
|
|
294
|
-
|
|
294
|
+
# Fetch through the end of THIS page (+1 to learn whether another follows) and drop what earlier pages already
|
|
295
|
+
# returned -- reading from the start every time made every page after the first a repeat of the first.
|
|
296
|
+
results = self.query(model, query, limit=offset + page_size + 1)[offset:]
|
|
295
297
|
|
|
296
298
|
has_more = len(results) > page_size
|
|
297
299
|
if has_more:
|
{altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/databaseFactory.py
RENAMED
|
@@ -19,7 +19,7 @@ from dataclasses import dataclass, field
|
|
|
19
19
|
from datetime import datetime
|
|
20
20
|
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union
|
|
21
21
|
|
|
22
|
-
from tenacity import retry, stop_after_attempt, wait_exponential
|
|
22
|
+
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
|
|
23
23
|
|
|
24
24
|
from .adapters.PostgreSQLAdapter import PostgreSQLAdapter
|
|
25
25
|
from .audit.context import AuditContext
|
|
@@ -32,6 +32,7 @@ from .errors import AdapterConfigurationError
|
|
|
32
32
|
from .models import PageRequest, PageResult
|
|
33
33
|
from .monitoring import HealthCheck, MetricsCollector, PerformanceMonitor
|
|
34
34
|
from .query import QueryBuilder
|
|
35
|
+
from .retry import _is_non_retryable
|
|
35
36
|
from .security import DataMasking, FieldEncryption
|
|
36
37
|
from .types import JsonDict, Lookup, ModelMeta
|
|
37
38
|
|
|
@@ -53,6 +54,31 @@ _DEFAULT_RETRY = retry(
|
|
|
53
54
|
reraise=True,
|
|
54
55
|
)
|
|
55
56
|
|
|
57
|
+
_TRANSIENT_MARKERS = (
|
|
58
|
+
"timeout", "timed out", "connection reset", "connection refused", "connection closed", "connection aborted",
|
|
59
|
+
"server closed the connection", "temporarily unavailable", "too many requests", "throttl", "service unavailable",
|
|
60
|
+
"serverbusy", "503", "could not connect",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _is_transient(exc: BaseException) -> bool:
|
|
65
|
+
if _is_non_retryable(exc):
|
|
66
|
+
return False
|
|
67
|
+
if isinstance(exc, (ConnectionError, TimeoutError)):
|
|
68
|
+
return True
|
|
69
|
+
s = str(exc).lower()
|
|
70
|
+
return any(m in s for m in _TRANSIENT_MARKERS)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _TRANSIENT_RETRY(fn):
|
|
74
|
+
return retry(
|
|
75
|
+
retry=retry_if_exception(_is_transient),
|
|
76
|
+
wait=wait_exponential(multiplier=0.5, min=0.5, max=6),
|
|
77
|
+
stop=stop_after_attempt(3),
|
|
78
|
+
reraise=True,
|
|
79
|
+
)(fn)
|
|
80
|
+
|
|
81
|
+
|
|
56
82
|
_UNIQUE_VIOLATION_MARKERS = (
|
|
57
83
|
"23505", # Postgres SQLSTATE
|
|
58
84
|
"duplicate key value violates", # Postgres message
|
|
@@ -140,6 +166,7 @@ def _extract_meta(model: Union[type, str]) -> ModelMeta:
|
|
|
140
166
|
provider=raw.get("provider"),
|
|
141
167
|
cache=raw.get("cache", False),
|
|
142
168
|
cache_ttl=raw.get("cache_ttl"),
|
|
169
|
+
encrypted_fields=tuple(raw.get("encrypted_fields") or ()),
|
|
143
170
|
)
|
|
144
171
|
return ModelMeta(storage="nosql", table=None, collection=None)
|
|
145
172
|
|
|
@@ -348,7 +375,42 @@ class DatabaseFactory:
|
|
|
348
375
|
return result
|
|
349
376
|
|
|
350
377
|
def _run(self, fn: Callable[[], Any]) -> Any:
|
|
351
|
-
|
|
378
|
+
"""Run one storage operation, retrying only TRANSIENT failures (connection loss, timeouts, throttling) with
|
|
379
|
+
exponential backoff when retries are enabled. Constraint violations, bad arguments and anything the retry
|
|
380
|
+
classifier marks non-retryable are raised at once -- a retry could not change their outcome."""
|
|
381
|
+
if not self._enable_retries:
|
|
382
|
+
return fn()
|
|
383
|
+
return _TRANSIENT_RETRY(fn)()
|
|
384
|
+
|
|
385
|
+
def _audit_write(
|
|
386
|
+
self, action: str, name: str, meta: ModelMeta, entity_id: Any, success: bool,
|
|
387
|
+
before: Optional[JsonDict], after: Optional[JsonDict], error: Optional[str],
|
|
388
|
+
) -> None:
|
|
389
|
+
"""Record a write in the audit log. Never raises: an audit failure must not turn a completed write into an
|
|
390
|
+
error. Encrypted fields are masked so the log never holds their plaintext."""
|
|
391
|
+
if not (self._enable_audit and self._audit):
|
|
392
|
+
return
|
|
393
|
+
|
|
394
|
+
def _mask(row: Optional[JsonDict]) -> Optional[JsonDict]:
|
|
395
|
+
if not row or not meta.encrypted_fields:
|
|
396
|
+
return row
|
|
397
|
+
return {k: ("[encrypted]" if k in meta.encrypted_fields else v) for k, v in row.items()}
|
|
398
|
+
|
|
399
|
+
try:
|
|
400
|
+
before_m, after_m = _mask(before), _mask(after)
|
|
401
|
+
changed = (
|
|
402
|
+
sorted(k for k in set(after_m or {}) | set(before_m or {}) if (before_m or {}).get(k) != (after_m or {}).get(k))
|
|
403
|
+
if before_m is not None and after_m is not None else None
|
|
404
|
+
)
|
|
405
|
+
if isinstance(entity_id, dict):
|
|
406
|
+
entity_id = entity_id.get("id")
|
|
407
|
+
self._audit.record(
|
|
408
|
+
action=action, model=name, entity_id=None if entity_id is None else str(entity_id),
|
|
409
|
+
storage_type=meta.storage, provider=meta.provider or "default", success=success,
|
|
410
|
+
before=before_m, after=after_m, error=error, changed_fields=changed,
|
|
411
|
+
)
|
|
412
|
+
except Exception as exc: # noqa: BLE001
|
|
413
|
+
logger.warning("Audit write failed (non-fatal): %s", exc)
|
|
352
414
|
|
|
353
415
|
def _is_sql(self, meta: ModelMeta, override: Optional[EngineOverride] = None) -> bool:
|
|
354
416
|
if override and override.force_sql:
|
|
@@ -432,9 +494,12 @@ class DatabaseFactory:
|
|
|
432
494
|
with monitor as m:
|
|
433
495
|
result = self._run(_op)
|
|
434
496
|
m.rows_affected = 1
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
497
|
+
else:
|
|
498
|
+
result = self._run(_op)
|
|
499
|
+
self._audit_write("create", name, meta, entity_id, True, None, after_plain, None)
|
|
500
|
+
return result
|
|
501
|
+
except Exception as exc:
|
|
502
|
+
self._audit_write("create", name, meta, entity_id, False, None, None, str(exc))
|
|
438
503
|
raise
|
|
439
504
|
|
|
440
505
|
# ──────────────────────────────────────────────────────────
|
|
@@ -467,14 +532,26 @@ class DatabaseFactory:
|
|
|
467
532
|
projected = bool(fields or omit)
|
|
468
533
|
sql_columns = select_columns(fields, omit, columns)
|
|
469
534
|
|
|
535
|
+
drop_deleted = False
|
|
470
536
|
if self._soft_delete and not include_deleted:
|
|
471
|
-
|
|
537
|
+
if self._is_sql(meta, engine_override):
|
|
538
|
+
query = self._apply_soft_delete_filter(query)
|
|
539
|
+
else:
|
|
540
|
+
# A NoSQL equality filter on `deleted_at = None` matches nothing when the attribute was never written
|
|
541
|
+
# (Azure Table, DynamoDB), so filter the returned rows instead.
|
|
542
|
+
drop_deleted = True
|
|
472
543
|
|
|
473
544
|
adapters = self._adapters_for(model, meta, engine_override)
|
|
474
545
|
use_external_cache = (
|
|
475
546
|
self._enable_cache and self._cache and getattr(meta, "cache", False) and not projected
|
|
476
547
|
)
|
|
477
548
|
encrypted_fields = getattr(meta, "encrypted_fields", [])
|
|
549
|
+
# The cache key must cover everything that changes the result: the query, the window, and the session variables
|
|
550
|
+
# (e.g. the tenant a row-level-security policy filters by) -- otherwise two callers share an entry.
|
|
551
|
+
cache_key_query = {
|
|
552
|
+
"q": query or {}, "limit": limit, "offset": offset,
|
|
553
|
+
"sv": sorted((session_vars or {}).items()), "e": bool(engine_override),
|
|
554
|
+
}
|
|
478
555
|
|
|
479
556
|
def _op() -> List[JsonDict]:
|
|
480
557
|
if self._is_sql(meta, engine_override):
|
|
@@ -497,22 +574,27 @@ class DatabaseFactory:
|
|
|
497
574
|
nosql_kwargs: Dict[str, Any] = {}
|
|
498
575
|
if projected:
|
|
499
576
|
nosql_kwargs = {"fields": fields, "omit": omit}
|
|
577
|
+
skip = offset or 0
|
|
500
578
|
raw = adapters.nosql.query(
|
|
501
|
-
cls, query=query, limit=limit
|
|
502
|
-
**nosql_kwargs,
|
|
579
|
+
cls, query=query, limit=(limit + skip) if limit else None,
|
|
580
|
+
no_cache=no_cache or bool(use_external_cache), **nosql_kwargs,
|
|
503
581
|
)
|
|
582
|
+
if skip:
|
|
583
|
+
raw = raw[skip:] # NoSQL adapters take no offset: read through it, then drop the skipped rows
|
|
584
|
+
if drop_deleted:
|
|
585
|
+
raw = [r for r in raw if not r.get("deleted_at")]
|
|
504
586
|
if self.encryption and encrypted_fields:
|
|
505
587
|
raw = [self.encryption.decrypt_fields(r, encrypted_fields) for r in raw]
|
|
506
588
|
if self._cache and use_external_cache and not no_cache:
|
|
507
589
|
ttl = cache_ttl or getattr(meta, "cache_ttl", 300)
|
|
508
590
|
try:
|
|
509
|
-
self._cache.set(name,
|
|
591
|
+
self._cache.set(name, cache_key_query, raw, ttl)
|
|
510
592
|
except Exception as _ce:
|
|
511
593
|
logger.warning("Cache set failed (non-fatal): %s", _ce)
|
|
512
594
|
return raw
|
|
513
595
|
|
|
514
596
|
if self._cache and use_external_cache and not no_cache:
|
|
515
|
-
cached = self._cache.get(name,
|
|
597
|
+
cached = self._cache.get(name, cache_key_query)
|
|
516
598
|
if cached is not None:
|
|
517
599
|
return cached
|
|
518
600
|
|
|
@@ -677,9 +759,12 @@ class DatabaseFactory:
|
|
|
677
759
|
with monitor as m:
|
|
678
760
|
result = self._run(_op)
|
|
679
761
|
m.rows_affected = 1
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
762
|
+
else:
|
|
763
|
+
result = self._run(_op)
|
|
764
|
+
self._audit_write("update", name, meta, entity_id, True, before, after_plain, None)
|
|
765
|
+
return result
|
|
766
|
+
except Exception as exc:
|
|
767
|
+
self._audit_write("update", name, meta, entity_id, False, before, None, str(exc))
|
|
683
768
|
raise
|
|
684
769
|
|
|
685
770
|
# ──────────────────────────────────────────────────────────
|
|
@@ -737,9 +822,12 @@ class DatabaseFactory:
|
|
|
737
822
|
with monitor as m:
|
|
738
823
|
result = self._run(_op)
|
|
739
824
|
m.rows_affected = 1
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
825
|
+
else:
|
|
826
|
+
result = self._run(_op)
|
|
827
|
+
self._audit_write("upsert", name, meta, (after_plain or {}).get('id'), True, None, after_plain, None)
|
|
828
|
+
return result
|
|
829
|
+
except Exception as exc:
|
|
830
|
+
self._audit_write("upsert", name, meta, None, False, None, None, str(exc))
|
|
743
831
|
raise
|
|
744
832
|
|
|
745
833
|
# ──────────────────────────────────────────────────────────
|
|
@@ -850,9 +938,12 @@ class DatabaseFactory:
|
|
|
850
938
|
with monitor as m:
|
|
851
939
|
result = self._run(_op)
|
|
852
940
|
m.rows_affected = 1
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
941
|
+
else:
|
|
942
|
+
result = self._run(_op)
|
|
943
|
+
self._audit_write("delete", name, meta, entity_id, True, before, None, None)
|
|
944
|
+
return result
|
|
945
|
+
except Exception as exc:
|
|
946
|
+
self._audit_write("delete", name, meta, entity_id, False, before, None, str(exc))
|
|
856
947
|
raise
|
|
857
948
|
|
|
858
949
|
# ──────────────────────────────────────────────────────────
|
|
@@ -906,8 +997,12 @@ class DatabaseFactory:
|
|
|
906
997
|
name = _model_name(model)
|
|
907
998
|
meta = _extract_meta(model)
|
|
908
999
|
|
|
1000
|
+
drop_deleted = False
|
|
909
1001
|
if self._soft_delete and not include_deleted:
|
|
910
|
-
|
|
1002
|
+
if self._is_sql(meta, engine_override):
|
|
1003
|
+
query = self._apply_soft_delete_filter(query)
|
|
1004
|
+
else:
|
|
1005
|
+
drop_deleted = True
|
|
911
1006
|
|
|
912
1007
|
adapters = self._adapters_for(model, meta, engine_override)
|
|
913
1008
|
encrypted_fields = getattr(meta, "encrypted_fields", [])
|
|
@@ -924,6 +1019,8 @@ class DatabaseFactory:
|
|
|
924
1019
|
else type(name, (), {"__polydb__": meta.__dict__})
|
|
925
1020
|
)
|
|
926
1021
|
raw, token = adapters.nosql.query_page(cls, query, page_size, continuation_token)
|
|
1022
|
+
if drop_deleted:
|
|
1023
|
+
raw = [r for r in raw if not r.get("deleted_at")]
|
|
927
1024
|
if self.encryption and encrypted_fields:
|
|
928
1025
|
raw = [self.encryption.decrypt_fields(r, encrypted_fields) for r in raw]
|
|
929
1026
|
return raw, token
|
|
@@ -131,6 +131,27 @@ class SchemaBuilder:
|
|
|
131
131
|
|
|
132
132
|
return sql
|
|
133
133
|
|
|
134
|
+
def to_add_missing_columns(self, table_name: str) -> List[str]:
|
|
135
|
+
"""`ALTER TABLE ... ADD COLUMN IF NOT EXISTS` for every non-key column. `CREATE TABLE IF NOT EXISTS` never adds a
|
|
136
|
+
column that was declared after the table was first created; appending these to the same migration makes an edited
|
|
137
|
+
column list reach existing tables. Added columns are always nullable (existing rows have no value) and never
|
|
138
|
+
UNIQUE; a declared default is kept."""
|
|
139
|
+
validate_table_name(table_name)
|
|
140
|
+
keys = set(self.primary_keys or [])
|
|
141
|
+
out: List[str] = []
|
|
142
|
+
for col in self.columns:
|
|
143
|
+
if col.name in keys or col.primary_key:
|
|
144
|
+
continue
|
|
145
|
+
parts = [validate_column_name(col.name)]
|
|
146
|
+
if col.type == ColumnType.VARCHAR and col.max_length:
|
|
147
|
+
parts.append(f"VARCHAR({col.max_length})")
|
|
148
|
+
else:
|
|
149
|
+
parts.append(col.type.value)
|
|
150
|
+
if col.default is not None:
|
|
151
|
+
parts.append(_render_default(col.default))
|
|
152
|
+
out.append(f"ALTER TABLE {table_name} ADD COLUMN IF NOT EXISTS {' '.join(parts)};")
|
|
153
|
+
return out
|
|
154
|
+
|
|
134
155
|
def to_create_indexes(self, table_name: str) -> List[str]:
|
|
135
156
|
"""Generate CREATE INDEX statements"""
|
|
136
157
|
statements = []
|
|
@@ -185,15 +206,31 @@ class MigrationManager:
|
|
|
185
206
|
|
|
186
207
|
# Check if already applied
|
|
187
208
|
existing = self.sql.execute(
|
|
188
|
-
"SELECT version FROM polydb_migrations WHERE version = %s", [version], fetch_one=True
|
|
209
|
+
"SELECT version, checksum FROM polydb_migrations WHERE version = %s", [version], fetch_one=True
|
|
189
210
|
)
|
|
190
211
|
|
|
191
|
-
if existing:
|
|
192
|
-
return False
|
|
193
|
-
|
|
194
212
|
# Calculate checksum
|
|
195
213
|
checksum = hashlib.sha256(up_sql.encode()).hexdigest()
|
|
196
214
|
|
|
215
|
+
if existing:
|
|
216
|
+
stored = existing.get("checksum") if isinstance(existing, dict) else None
|
|
217
|
+
if not stored or stored == checksum:
|
|
218
|
+
return False
|
|
219
|
+
# The DDL under this version was edited (a column added, an index changed). Re-apply it -- the statements
|
|
220
|
+
# this codebase generates are idempotent (IF NOT EXISTS) -- and remember the new checksum. A failure here
|
|
221
|
+
# must not stop a deployment from starting: keep the old checksum so the next start tries again.
|
|
222
|
+
try:
|
|
223
|
+
self.sql.execute(up_sql)
|
|
224
|
+
self.sql.execute(
|
|
225
|
+
"UPDATE polydb_migrations SET checksum = %s WHERE version = %s", [checksum, version]
|
|
226
|
+
)
|
|
227
|
+
return True
|
|
228
|
+
except Exception as exc: # noqa: BLE001
|
|
229
|
+
import logging
|
|
230
|
+
|
|
231
|
+
logging.getLogger(__name__).warning("Re-applying edited migration %s failed (kept old): %s", version, exc)
|
|
232
|
+
return False
|
|
233
|
+
|
|
197
234
|
try:
|
|
198
235
|
# Execute migration
|
|
199
236
|
self.sql.execute(up_sql)
|
|
@@ -24,6 +24,8 @@ class ModelMeta:
|
|
|
24
24
|
provider: Optional[str] = None
|
|
25
25
|
cache: bool = False
|
|
26
26
|
cache_ttl: Optional[int] = None
|
|
27
|
+
# Fields stored encrypted when the factory has encryption enabled (a tuple: the dataclass is frozen/hashable).
|
|
28
|
+
encrypted_fields: Tuple[str, ...] = ()
|
|
27
29
|
|
|
28
30
|
|
|
29
31
|
@runtime_checkable
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""DatabaseFactory wiring: encrypted_fields reach the factory, writes are audited, transient failures retry, and the
|
|
2
|
+
read-cache key covers the window and session variables. Unit level: no database, fake adapters."""
|
|
3
|
+
import types
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from polydb.databaseFactory import DatabaseFactory, _extract_meta
|
|
8
|
+
from polydb.security import FieldEncryption
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _Sql:
|
|
12
|
+
def __init__(self):
|
|
13
|
+
self.rows, self.fail_times, self.calls = {}, 0, 0
|
|
14
|
+
|
|
15
|
+
def insert(self, table, data, session_vars=None):
|
|
16
|
+
self.calls += 1
|
|
17
|
+
if self.fail_times:
|
|
18
|
+
self.fail_times -= 1
|
|
19
|
+
raise TimeoutError("connection timed out")
|
|
20
|
+
self.rows[data["id"]] = dict(data)
|
|
21
|
+
return dict(data)
|
|
22
|
+
|
|
23
|
+
def select(self, table, query=None, limit=None, offset=None, session_vars=None, fields=None):
|
|
24
|
+
self.calls += 1
|
|
25
|
+
return [dict(r) for r in self.rows.values()]
|
|
26
|
+
|
|
27
|
+
def update(self, table, entity_id, data, session_vars=None):
|
|
28
|
+
eid = entity_id["id"] if isinstance(entity_id, dict) else entity_id
|
|
29
|
+
self.rows[eid].update(data)
|
|
30
|
+
return dict(self.rows[eid])
|
|
31
|
+
|
|
32
|
+
def delete(self, table, entity_id, session_vars=None):
|
|
33
|
+
self.rows.pop(entity_id["id"] if isinstance(entity_id, dict) else entity_id, None)
|
|
34
|
+
return {"deleted": True}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _Cache:
|
|
38
|
+
def __init__(self):
|
|
39
|
+
self.store, self.keys = {}, []
|
|
40
|
+
|
|
41
|
+
def get(self, model, query):
|
|
42
|
+
self.keys.append(query)
|
|
43
|
+
return self.store.get((model, repr(sorted(query.items()))))
|
|
44
|
+
|
|
45
|
+
def set(self, model, query, value, ttl=None):
|
|
46
|
+
self.store[(model, repr(sorted(query.items())))] = value
|
|
47
|
+
|
|
48
|
+
def invalidate(self, model, query=None):
|
|
49
|
+
self.store.clear()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _Audit:
|
|
53
|
+
def __init__(self):
|
|
54
|
+
self.records = []
|
|
55
|
+
|
|
56
|
+
def record(self, **kw):
|
|
57
|
+
self.records.append(kw)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _factory(sql, *, audit=None, cache=None, encryption=None, retries=True):
|
|
61
|
+
f = object.__new__(DatabaseFactory)
|
|
62
|
+
f._enable_retries, f._enable_audit, f._audit = retries, audit is not None, audit
|
|
63
|
+
f._enable_cache, f._cache, f._soft_delete = cache is not None, cache, False
|
|
64
|
+
f.encryption, f.metrics = encryption, None
|
|
65
|
+
f._adapters_for = lambda model, meta, override=None: types.SimpleNamespace(sql=sql, nosql=None)
|
|
66
|
+
return f
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Note:
|
|
70
|
+
__polydb__ = {"storage": "sql", "table": "notes", "encrypted_fields": ["secret"], "cache": True, "cache_ttl": 60}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_encrypted_fields_reach_the_model_metadata():
|
|
74
|
+
assert _extract_meta(Note).encrypted_fields == ("secret",)
|
|
75
|
+
assert _extract_meta(type("X", (), {"__polydb__": {"storage": "sql", "table": "t"}})).encrypted_fields == ()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_encryption_runs_for_a_model_that_declares_encrypted_fields(monkeypatch):
|
|
79
|
+
monkeypatch.setenv("POLYDB_ENCRYPTION_KEY", __import__("base64").b64encode(b"k" * 32).decode())
|
|
80
|
+
sql = _Sql()
|
|
81
|
+
try:
|
|
82
|
+
enc = FieldEncryption()
|
|
83
|
+
except Exception:
|
|
84
|
+
pytest.skip("FieldEncryption needs a configured key in this environment")
|
|
85
|
+
f = _factory(sql, encryption=enc)
|
|
86
|
+
out = f.create(Note, {"id": "1", "secret": "plain"})
|
|
87
|
+
assert sql.rows["1"]["secret"] != "plain" # stored encrypted
|
|
88
|
+
assert out["secret"] == "plain" # returned decrypted
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_writes_are_audited_and_encrypted_values_are_masked():
|
|
92
|
+
sql, audit = _Sql(), _Audit()
|
|
93
|
+
f = _factory(sql, audit=audit)
|
|
94
|
+
f.create(Note, {"id": "1", "secret": "plain", "title": "a"})
|
|
95
|
+
kinds = [r["action"] for r in audit.records]
|
|
96
|
+
assert kinds == ["create"] and audit.records[0]["success"] is True
|
|
97
|
+
assert audit.records[0]["after"]["secret"] == "[encrypted]" and audit.records[0]["after"]["title"] == "a"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_an_audit_failure_never_breaks_the_write():
|
|
101
|
+
class Boom:
|
|
102
|
+
def record(self, **kw):
|
|
103
|
+
raise RuntimeError("audit store down")
|
|
104
|
+
sql = _Sql()
|
|
105
|
+
assert _factory(sql, audit=Boom()).create(Note, {"id": "1"})["id"] == "1"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_transient_errors_are_retried_and_constraint_errors_are_not(monkeypatch):
|
|
109
|
+
import polydb.databaseFactory as df
|
|
110
|
+
monkeypatch.setattr(df, "_TRANSIENT_RETRY", lambda fn: __import__("tenacity").retry(
|
|
111
|
+
retry=df.retry_if_exception(df._is_transient), stop=df.stop_after_attempt(3), reraise=True)(fn))
|
|
112
|
+
sql = _Sql()
|
|
113
|
+
sql.fail_times = 2
|
|
114
|
+
_factory(sql).create(Note, {"id": "1"})
|
|
115
|
+
assert sql.calls == 3
|
|
116
|
+
|
|
117
|
+
calls = []
|
|
118
|
+
f = _factory(sql)
|
|
119
|
+
|
|
120
|
+
def op():
|
|
121
|
+
calls.append(1)
|
|
122
|
+
raise ValueError('duplicate key value violates unique constraint "x"')
|
|
123
|
+
with pytest.raises(ValueError):
|
|
124
|
+
f._run(op)
|
|
125
|
+
assert len(calls) == 1
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_retries_can_be_turned_off():
|
|
129
|
+
f = _factory(_Sql(), retries=False)
|
|
130
|
+
calls = []
|
|
131
|
+
|
|
132
|
+
def op():
|
|
133
|
+
calls.append(1)
|
|
134
|
+
raise TimeoutError("timeout")
|
|
135
|
+
with pytest.raises(TimeoutError):
|
|
136
|
+
f._run(op)
|
|
137
|
+
assert len(calls) == 1
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_the_read_cache_key_covers_session_variables_and_the_window():
|
|
141
|
+
sql, cache = _Sql(), _Cache()
|
|
142
|
+
sql.rows["1"] = {"id": "1"}
|
|
143
|
+
f = _factory(sql, cache=cache)
|
|
144
|
+
f.read(Note, {"x": 1}, session_vars={"app.tenant_id": "a"})
|
|
145
|
+
f.read(Note, {"x": 1}, session_vars={"app.tenant_id": "b"}) # must NOT be served tenant a's entry
|
|
146
|
+
f.read(Note, {"x": 1}, session_vars={"app.tenant_id": "a"}, limit=5)
|
|
147
|
+
assert sql.calls == 3
|
|
148
|
+
f.read(Note, {"x": 1}, session_vars={"app.tenant_id": "a"}) # same caller, same query: a hit
|
|
149
|
+
assert sql.calls == 3
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---------------- reported adapter findings ----------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
def test_nosql_query_page_advances_instead_of_repeating_the_first_page():
|
|
155
|
+
from polydb.base.NoSQLKVAdapter import NoSQLKVAdapter
|
|
156
|
+
|
|
157
|
+
class A(NoSQLKVAdapter):
|
|
158
|
+
def _query_raw(self, model, filters, limit, select=None):
|
|
159
|
+
rows = [{"id": str(i)} for i in range(10)]
|
|
160
|
+
return rows[:limit] if limit else rows
|
|
161
|
+
|
|
162
|
+
a = A()
|
|
163
|
+
first, token = a.query_page(object, {}, 4)
|
|
164
|
+
second, token2 = a.query_page(object, {}, 4, token)
|
|
165
|
+
third, token3 = a.query_page(object, {}, 4, token2)
|
|
166
|
+
assert [r["id"] for r in first] == ["0", "1", "2", "3"]
|
|
167
|
+
assert [r["id"] for r in second] == ["4", "5", "6", "7"]
|
|
168
|
+
assert [r["id"] for r in third] == ["8", "9"] and token3 is None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_dynamodb_record_over_the_limit_is_not_replaced_by_a_stub():
|
|
172
|
+
from polydb.adapters.DynamoDBAdapter import DynamoDBAdapter
|
|
173
|
+
|
|
174
|
+
data = {"id": "1", "blob": "x" * 500_000}
|
|
175
|
+
store, key = DynamoDBAdapter._check_overflow(object.__new__(DynamoDBAdapter), data)
|
|
176
|
+
assert store is data and key is None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_soft_deleted_rows_are_hidden_on_nosql_even_when_the_attribute_was_never_written():
|
|
180
|
+
class _NoSql:
|
|
181
|
+
def query(self, cls, query=None, limit=None, no_cache=False, **kw):
|
|
182
|
+
return [{"id": "1"}, {"id": "2", "deleted_at": "2026-01-01"}, {"id": "3", "deleted_at": None}]
|
|
183
|
+
|
|
184
|
+
class Doc:
|
|
185
|
+
__polydb__ = {"storage": "nosql"}
|
|
186
|
+
|
|
187
|
+
f = _factory(None)
|
|
188
|
+
f._soft_delete = True
|
|
189
|
+
f._adapters_for = lambda model, meta, override=None: types.SimpleNamespace(sql=None, nosql=_NoSql())
|
|
190
|
+
assert [r["id"] for r in f.read(Doc, {})] == ["1", "3"]
|
|
191
|
+
assert len(f.read(Doc, {}, include_deleted=True)) == 3
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_nosql_read_honours_offset():
|
|
195
|
+
seen = {}
|
|
196
|
+
|
|
197
|
+
class _NoSql:
|
|
198
|
+
def query(self, cls, query=None, limit=None, no_cache=False, **kw):
|
|
199
|
+
seen["limit"] = limit
|
|
200
|
+
return [{"id": str(i)} for i in range(10)][:limit]
|
|
201
|
+
|
|
202
|
+
class Doc:
|
|
203
|
+
__polydb__ = {"storage": "nosql"}
|
|
204
|
+
|
|
205
|
+
f = _factory(None)
|
|
206
|
+
f._adapters_for = lambda model, meta, override=None: types.SimpleNamespace(sql=None, nosql=_NoSql())
|
|
207
|
+
out = f.read(Doc, {}, limit=3, offset=4)
|
|
208
|
+
assert [r["id"] for r in out] == ["4", "5", "6"] and seen["limit"] == 7
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_edited_migration_ddl_is_reapplied_and_unchanged_ddl_is_not():
|
|
212
|
+
import hashlib
|
|
213
|
+
|
|
214
|
+
from polydb.schema import MigrationManager
|
|
215
|
+
|
|
216
|
+
class _S:
|
|
217
|
+
def __init__(self):
|
|
218
|
+
self.sql_log, self.stored = [], {}
|
|
219
|
+
|
|
220
|
+
def execute(self, sql, params=None, fetch_one=False, **kw):
|
|
221
|
+
self.sql_log.append(sql)
|
|
222
|
+
if sql.startswith("SELECT version"):
|
|
223
|
+
v = params[0]
|
|
224
|
+
return {"version": v, "checksum": self.stored[v]} if v in self.stored else None
|
|
225
|
+
if sql.startswith("UPDATE polydb_migrations"):
|
|
226
|
+
self.stored[params[1]] = params[0]
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
def insert(self, table, data):
|
|
230
|
+
self.stored[data["version"]] = data["checksum"]
|
|
231
|
+
|
|
232
|
+
s = _S()
|
|
233
|
+
m = object.__new__(MigrationManager)
|
|
234
|
+
m.sql = s
|
|
235
|
+
assert m.apply_migration("v1", "n", "CREATE TABLE a (x int);") is True
|
|
236
|
+
assert m.apply_migration("v1", "n", "CREATE TABLE a (x int);") is False # unchanged
|
|
237
|
+
assert m.apply_migration("v1", "n", "CREATE TABLE a (x int); ALTER TABLE a ADD COLUMN IF NOT EXISTS y int;") is True
|
|
238
|
+
assert s.stored["v1"] == hashlib.sha256(b"CREATE TABLE a (x int); ALTER TABLE a ADD COLUMN IF NOT EXISTS y int;").hexdigest()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_schema_builder_emits_add_column_statements_for_non_key_columns():
|
|
242
|
+
from polydb.schema import Column, ColumnType, SchemaBuilder
|
|
243
|
+
|
|
244
|
+
b = SchemaBuilder()
|
|
245
|
+
b.add_column(Column(name="id", type=ColumnType.VARCHAR, nullable=False, primary_key=True))
|
|
246
|
+
b.add_column(Column(name="title", type=ColumnType.TEXT, nullable=False))
|
|
247
|
+
assert b.to_add_missing_columns("notes") == ["ALTER TABLE notes ADD COLUMN IF NOT EXISTS title TEXT;"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/requirements-generic.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/S3Adapter.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/__init__.py
RENAMED
|
File without changes
|
{altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/advanced_query.py
RENAMED
|
File without changes
|
|
File without changes
|
{altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/audit/AuditStorage.py
RENAMED
|
File without changes
|
{altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/audit/__init__.py
RENAMED
|
File without changes
|
{altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/audit/context.py
RENAMED
|
File without changes
|
{altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/audit/manager.py
RENAMED
|
File without changes
|
{altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/audit/models.py
RENAMED
|
File without changes
|
|
File without changes
|
{altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/base/QueueAdapter.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/base/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/monitoring.py
RENAMED
|
File without changes
|
{altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/multitenancy.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/overflow_gc.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/services/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/validation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_atomic_decrement.py
RENAMED
|
File without changes
|
|
File without changes
|
{altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_audit_hmac.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_blockchain.py
RENAMED
|
File without changes
|
|
File without changes
|
{altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_cloud_factory.py
RENAMED
|
File without changes
|
{altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_field_encryption.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_monitoring_health.py
RENAMED
|
File without changes
|
{altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_multi_engine.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_observability.py
RENAMED
|
File without changes
|
{altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_overflow_gc.py
RENAMED
|
File without changes
|
{altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_postgresql.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_read_projection.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_secrets_aws.py
RENAMED
|
File without changes
|
{altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_secrets_vault.py
RENAMED
|
File without changes
|
{altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_session_vars.py
RENAMED
|
File without changes
|
|
File without changes
|