altcodepro-polydb-python 2.5.18__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.
Files changed (127) hide show
  1. {altcodepro_polydb_python-2.5.18/src/altcodepro_polydb_python.egg-info → altcodepro_polydb_python-2.5.20}/PKG-INFO +1 -1
  2. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/pyproject.toml +1 -1
  3. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20/src/altcodepro_polydb_python.egg-info}/PKG-INFO +1 -1
  4. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/altcodepro_polydb_python.egg-info/SOURCES.txt +2 -0
  5. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/PolyDB.py +4 -0
  6. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/__init__.py +1 -1
  7. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/AzureTableStorageAdapter.py +32 -2
  8. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/DynamoDBAdapter.py +7 -0
  9. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/PostgreSQLAdapter.py +5 -1
  10. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/base/NoSQLKVAdapter.py +45 -3
  11. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/databaseFactory.py +144 -24
  12. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/schema.py +41 -4
  13. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/types.py +2 -0
  14. altcodepro_polydb_python-2.5.20/tests/test_factory_wiring.py +247 -0
  15. altcodepro_polydb_python-2.5.20/tests/test_read_projection.py +169 -0
  16. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/LICENSE +0 -0
  17. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/MANIFEST.in +0 -0
  18. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/README.md +0 -0
  19. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/requirements-aws.txt +0 -0
  20. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/requirements-azure.txt +0 -0
  21. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/requirements-dev.txt +0 -0
  22. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/requirements-gcp.txt +0 -0
  23. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/requirements-generic.txt +0 -0
  24. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/requirements.txt +0 -0
  25. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/setup.cfg +0 -0
  26. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/setup.py +0 -0
  27. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/altcodepro_polydb_python.egg-info/dependency_links.txt +0 -0
  28. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/altcodepro_polydb_python.egg-info/requires.txt +0 -0
  29. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/altcodepro_polydb_python.egg-info/top_level.txt +0 -0
  30. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/AWSSecretsManagerAdapter.py +0 -0
  31. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/AzureBlobStorageAdapter.py +0 -0
  32. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/AzureFileStorageAdapter.py +0 -0
  33. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/AzureKeyVaultAdapter.py +0 -0
  34. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/AzureQueueAdapter.py +0 -0
  35. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/BlockchainBlobAdapter.py +0 -0
  36. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/BlockchainFileAdapter.py +0 -0
  37. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/BlockchainKVAdapter.py +0 -0
  38. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/BlockchainQueueAdapter.py +0 -0
  39. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/EFSAdapter.py +0 -0
  40. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/FirestoreAdapter.py +0 -0
  41. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/GCPFilestoreAdapter.py +0 -0
  42. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/GCPPubSubAdapter.py +0 -0
  43. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/GCPSecretManagerAdapter.py +0 -0
  44. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/GCPStorageAdapter.py +0 -0
  45. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/KafkaQueueAdapter.py +0 -0
  46. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/MongoDBAdapter.py +0 -0
  47. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/RabbitMQAdapter.py +0 -0
  48. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/S3Adapter.py +0 -0
  49. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/S3CompatibleAdapter.py +0 -0
  50. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/SQSAdapter.py +0 -0
  51. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/VaultAdapter.py +0 -0
  52. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/VercelBlobAdapter.py +0 -0
  53. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/VercelFileAdapter.py +0 -0
  54. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/VercelKVAdapter.py +0 -0
  55. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/VercelQueueAdapter.py +0 -0
  56. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/__init__.py +0 -0
  57. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/advanced_query.py +0 -0
  58. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/aio.py +0 -0
  59. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/audit/AuditStorage.py +0 -0
  60. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/audit/__init__.py +0 -0
  61. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/audit/context.py +0 -0
  62. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/audit/manager.py +0 -0
  63. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/audit/models.py +0 -0
  64. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/base/ObjectStorageAdapter.py +0 -0
  65. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/base/QueueAdapter.py +0 -0
  66. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/base/SecretsAdapter.py +0 -0
  67. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/base/SharedFilesAdapter.py +0 -0
  68. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/base/__init__.py +0 -0
  69. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/batch.py +0 -0
  70. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/cache.py +0 -0
  71. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/cloudDatabaseFactory.py +0 -0
  72. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/errors.py +0 -0
  73. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/json_safe.py +0 -0
  74. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/models.py +0 -0
  75. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/monitoring.py +0 -0
  76. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/multitenancy.py +0 -0
  77. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/observability/__init__.py +0 -0
  78. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/observability/logging.py +0 -0
  79. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/overflow_gc.py +0 -0
  80. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/py.typed +0 -0
  81. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/query.py +0 -0
  82. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/registry.py +0 -0
  83. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/retry.py +0 -0
  84. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/security.py +0 -0
  85. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/services/__init__.py +0 -0
  86. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/services/compliance_service.py +0 -0
  87. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/services/security_service.py +0 -0
  88. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/utils.py +0 -0
  89. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/src/polydb/validation.py +0 -0
  90. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_advanced_query_injection.py +0 -0
  91. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_async_api.py +0 -0
  92. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_atomic_decrement.py +0 -0
  93. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_audit_chain_cutover.py +0 -0
  94. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_audit_hmac.py +0 -0
  95. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_aws.py +0 -0
  96. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_azure.py +0 -0
  97. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_azure_queue_receipt_handle.py +0 -0
  98. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_azure_table_empty_filter.py +0 -0
  99. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_azure_table_id_property_persisted.py +0 -0
  100. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_azure_table_id_return_value_sanitization_mismatch.py +0 -0
  101. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_azure_table_large_record_patch_roundtrip.py +0 -0
  102. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_azure_table_unpack_entity.py +0 -0
  103. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_blockchain.py +0 -0
  104. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_blockchain_overflow_guard.py +0 -0
  105. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_cloud_factory.py +0 -0
  106. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_field_encryption.py +0 -0
  107. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_gcp.py +0 -0
  108. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_kafka.py +0 -0
  109. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_like_pattern_escaping.py +0 -0
  110. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_mongodb.py +0 -0
  111. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_monitoring_health.py +0 -0
  112. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_multi_engine.py +0 -0
  113. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_multitenancy_ddl_injection.py +0 -0
  114. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_nosql_kv_overflow_write_path.py +0 -0
  115. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_nosql_operator_injection.py +0 -0
  116. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_observability.py +0 -0
  117. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_overflow_gc.py +0 -0
  118. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_postgresql.py +0 -0
  119. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_postgresql_deserialize_row.py +0 -0
  120. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_queue_extend_delay_cancel.py +0 -0
  121. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_rabbitmq.py +0 -0
  122. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_redis_kv.py +0 -0
  123. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_schema_ddl_injection.py +0 -0
  124. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_secrets_aws.py +0 -0
  125. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_secrets_vault.py +0 -0
  126. {altcodepro_polydb_python-2.5.18 → altcodepro_polydb_python-2.5.20}/tests/test_session_vars.py +0 -0
  127. {altcodepro_polydb_python-2.5.18 → 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.18
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.18"
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.18
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
@@ -115,6 +116,7 @@ tests/test_postgresql.py
115
116
  tests/test_postgresql_deserialize_row.py
116
117
  tests/test_queue_extend_delay_cancel.py
117
118
  tests/test_rabbitmq.py
119
+ tests/test_read_projection.py
118
120
  tests/test_redis_kv.py
119
121
  tests/test_schema_ddl_injection.py
120
122
  tests/test_secrets_aws.py
@@ -158,6 +158,8 @@ class PolyDB:
158
158
  cache_ttl: Optional[int] = None,
159
159
  include_deleted: bool = False,
160
160
  engine_override: Optional[EngineOverride] = None,
161
+ fields: Optional[List[str]] = None,
162
+ omit: Optional[List[str]] = None,
161
163
  ) -> List[JsonDict]:
162
164
  return self.db.read(
163
165
  model,
@@ -168,6 +170,8 @@ class PolyDB:
168
170
  cache_ttl=cache_ttl,
169
171
  include_deleted=include_deleted,
170
172
  engine_override=engine_override,
173
+ fields=fields,
174
+ omit=omit,
171
175
  )
172
176
 
173
177
  def read_one(
@@ -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.18"
7
+ __version__ = "2.5.20"
8
8
 
9
9
  from .aio import AsyncDatabaseFactory, AsyncPolyDB
10
10
  from .audit.context import AuditContext
@@ -633,9 +633,35 @@ class AzureTableStorageAdapter(NoSQLKVAdapter):
633
633
  return None
634
634
  raise NoSQLError(f"Azure Table get failed: {str(e)}")
635
635
 
636
+ # `fields` / `omit` are sent to Azure as `$select`, so unrequested properties never leave the service.
637
+ SUPPORTS_SELECT_PUSHDOWN = True
638
+
639
+ def _select_properties(self, select: Optional[List[str]]) -> Optional[List[str]]:
640
+ """The Azure property names to `$select` for the requested field names -- the requested ones, plus what `_unpack_entity`
641
+ needs to rebuild a row (the keys, the model tag, and the sanitized-name map). A per-property blob overflow is stored in
642
+ the property itself, so selecting the property is enough for `_restore_overflow_properties`. None = no pushdown: if any
643
+ requested name is rewritten by `_sanitize_prop_name` (so its stored name could carry a collision suffix), the whole row is
644
+ fetched and trimmed afterwards instead of risking a silently missing column."""
645
+ if not select:
646
+ return None
647
+ props: List[str] = []
648
+ for name in select:
649
+ safe = self._sanitize_prop_name(name)
650
+ if safe != str(name):
651
+ return None
652
+ props.append(safe)
653
+ for needed in ("PartitionKey", "RowKey", _MODEL_FIELD, "__keymap__"):
654
+ if needed not in props:
655
+ props.append(needed)
656
+ return props
657
+
636
658
  @retry(max_attempts=3, delay=1.0, exceptions=(NoSQLError,))
637
659
  def _query_raw(
638
- self, model: type, filters: Dict[str, Any], limit: Optional[int]
660
+ self,
661
+ model: type,
662
+ filters: Dict[str, Any],
663
+ limit: Optional[int],
664
+ select: Optional[List[str]] = None,
639
665
  ) -> List[JsonDict]:
640
666
  try:
641
667
  table_client = self._get_table_client(model)
@@ -685,7 +711,11 @@ class AzureTableStorageAdapter(NoSQLKVAdapter):
685
711
  else:
686
712
  query_filter = " and ".join(parts)
687
713
 
688
- entities = table_client.query_entities(query_filter=query_filter)
714
+ props = self._select_properties(select)
715
+ if props is None:
716
+ entities = table_client.query_entities(query_filter=query_filter)
717
+ else:
718
+ entities = table_client.query_entities(query_filter=query_filter, select=props)
689
719
 
690
720
  results: List[JsonDict] = []
691
721
  count = 0
@@ -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]:
@@ -487,8 +487,12 @@ class PostgreSQLAdapter:
487
487
  tx: Optional[Any] = None,
488
488
  *,
489
489
  session_vars: Optional[Dict[str, str]] = None,
490
+ fields: Optional[List[str]] = None,
490
491
  ) -> List[JsonDict]:
491
492
  table = validate_table_name(table)
493
+ if fields:
494
+ for f in fields:
495
+ validate_column_name(f)
492
496
  conn = tx
493
497
  own_conn = False
494
498
  if not conn:
@@ -498,7 +502,7 @@ class PostgreSQLAdapter:
498
502
  try:
499
503
  self._apply_session_vars(conn, session_vars)
500
504
  cursor = conn.cursor()
501
- sql = f"SELECT * FROM {table}"
505
+ sql = f"SELECT {', '.join(fields)} FROM {table}" if fields else f"SELECT * FROM {table}"
502
506
  params: List[Any] = []
503
507
 
504
508
  if query:
@@ -17,9 +17,43 @@ if TYPE_CHECKING:
17
17
  from ..models import PartitionConfig
18
18
 
19
19
 
20
+ def project_rows(
21
+ rows: List[JsonDict], fields: Optional[List[str]] = None, omit: Optional[List[str]] = None
22
+ ) -> List[JsonDict]:
23
+ """The exact `fields` / `omit` semantics every NoSQL read shares: `fields` keeps only those keys, `omit` drops those keys
24
+ (`fields` wins if both are given). Applied by the base `query()` AFTER overflow rehydration, so it is correct for every
25
+ adapter whether or not it also pushes the projection down to the store."""
26
+ if fields:
27
+ keep = set(fields)
28
+ return [{k: v for k, v in r.items() if k in keep} for r in rows]
29
+ if omit:
30
+ drop = set(omit)
31
+ return [{k: v for k, v in r.items() if k not in drop} for r in rows]
32
+ return rows
33
+
34
+
35
+ def select_columns(
36
+ fields: Optional[List[str]], omit: Optional[List[str]], columns: Optional[List[str]]
37
+ ) -> Optional[List[str]]:
38
+ """The column list a store can be asked for: `fields` as given, or -- for `omit` -- the model's known `columns` minus the
39
+ omitted ones. None means "no column list can be derived" (no projection, or `omit` on a model whose columns are unknown):
40
+ the read then fetches whole rows and `project_rows` trims them."""
41
+ if fields:
42
+ return list(fields)
43
+ if omit and columns:
44
+ drop = set(omit)
45
+ return [c for c in columns if c not in drop]
46
+ return None
47
+
48
+
20
49
  class NoSQLKVAdapter:
21
50
  """Base with auto-overflow and LINQ support"""
22
51
 
52
+ # An adapter sets this True when its `_query_raw` accepts `select=[property names]` and really sends it to the store (Azure
53
+ # Table's `$select`). False (the default) means `fields`/`omit` are applied after the rows come back -- same result, no
54
+ # network saving. Opt in per adapter only once the adapter's own rehydration is proven to survive a narrowed row.
55
+ SUPPORTS_SELECT_PUSHDOWN = False
56
+
23
57
  def __init__(
24
58
  self,
25
59
  partition_config: Optional[PartitionConfig] = None,
@@ -242,16 +276,24 @@ class NoSQLKVAdapter:
242
276
  limit: Optional[int] = None,
243
277
  no_cache: bool = False,
244
278
  cache_ttl: Optional[int] = None,
279
+ fields: Optional[List[str]] = None,
280
+ omit: Optional[List[str]] = None,
245
281
  ) -> List[JsonDict]:
246
- results = self._query_raw(model, query or {}, limit)
247
- return [self._retrieve_overflow(r) for r in results]
282
+ select = select_columns(fields, omit, getattr(model, "__polydb__", {}).get("columns"))
283
+ if select is not None and self.SUPPORTS_SELECT_PUSHDOWN:
284
+ results = self._query_raw(model, query or {}, limit, select=select)
285
+ else:
286
+ results = self._query_raw(model, query or {}, limit)
287
+ return project_rows([self._retrieve_overflow(r) for r in results], fields, omit)
248
288
 
249
289
  def query_page(
250
290
  self, model: type, query: Lookup, page_size: int, continuation_token: Optional[str] = None
251
291
  ) -> Tuple[List[JsonDict], Optional[str]]:
252
292
  # Basic implementation - override per provider
253
293
  offset = int(continuation_token) if continuation_token else 0
254
- results = self.query(model, query, limit=page_size + 1)
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:]
255
297
 
256
298
  has_more = len(results) > page_size
257
299
  if has_more:
@@ -19,12 +19,12 @@ 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
26
26
  from .audit.manager import AuditManager
27
- from .base.NoSQLKVAdapter import NoSQLKVAdapter
27
+ from .base.NoSQLKVAdapter import NoSQLKVAdapter, project_rows, select_columns
28
28
  from .batch import BatchOperations
29
29
  from .cache import CacheWarmer, RedisCacheEngine
30
30
  from .cloudDatabaseFactory import CloudDatabaseFactory
@@ -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
- return fn()
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
- return result
436
- return self._run(_op)
437
- except Exception:
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
  # ──────────────────────────────────────────────────────────
@@ -453,43 +518,83 @@ class DatabaseFactory:
453
518
  include_deleted: bool = False,
454
519
  engine_override: Optional[EngineOverride] = None,
455
520
  session_vars: Optional[Dict[str, str]] = None,
521
+ fields: Optional[List[str]] = None,
522
+ omit: Optional[List[str]] = None,
456
523
  ) -> List[JsonDict]:
524
+ """`fields` returns only those keys; `omit` returns every key except those (`fields` wins if both are given). SQL
525
+ names the columns in the SELECT; NoSQL adapters that can ($select on Azure Table) send the property list to the store,
526
+ the rest trim the rows after the read -- same result either way. `omit` needs the model's `__polydb__["columns"]` to
527
+ be turned into a column list for the store; without it the whole row is fetched and trimmed. A projected read never
528
+ goes through the external cache (its key does not include the projection)."""
457
529
  name = _model_name(model)
458
530
  meta = _extract_meta(model)
531
+ columns = (getattr(model, "__polydb__", None) or {}).get("columns") if isinstance(model, type) else None
532
+ projected = bool(fields or omit)
533
+ sql_columns = select_columns(fields, omit, columns)
459
534
 
535
+ drop_deleted = False
460
536
  if self._soft_delete and not include_deleted:
461
- query = self._apply_soft_delete_filter(query)
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
462
543
 
463
544
  adapters = self._adapters_for(model, meta, engine_override)
464
- use_external_cache = self._enable_cache and self._cache and getattr(meta, "cache", False)
545
+ use_external_cache = (
546
+ self._enable_cache and self._cache and getattr(meta, "cache", False) and not projected
547
+ )
465
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
+ }
466
555
 
467
556
  def _op() -> List[JsonDict]:
468
557
  if self._is_sql(meta, engine_override):
469
- raw = adapters.sql.select(
470
- meta.table, query, limit=limit, offset=offset, session_vars=session_vars
471
- )
558
+ if sql_columns is not None:
559
+ raw = adapters.sql.select(
560
+ meta.table, query, limit=limit, offset=offset,
561
+ session_vars=session_vars, fields=sql_columns,
562
+ )
563
+ else:
564
+ raw = adapters.sql.select(
565
+ meta.table, query, limit=limit, offset=offset, session_vars=session_vars
566
+ )
567
+ raw = project_rows(raw, fields, omit)
472
568
  else:
473
569
  cls = (
474
570
  model
475
571
  if isinstance(model, type)
476
572
  else type(name, (), {"__polydb__": meta.__dict__})
477
573
  )
574
+ nosql_kwargs: Dict[str, Any] = {}
575
+ if projected:
576
+ nosql_kwargs = {"fields": fields, "omit": omit}
577
+ skip = offset or 0
478
578
  raw = adapters.nosql.query(
479
- cls, query=query, limit=limit, no_cache=no_cache or bool(use_external_cache)
579
+ cls, query=query, limit=(limit + skip) if limit else None,
580
+ no_cache=no_cache or bool(use_external_cache), **nosql_kwargs,
480
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")]
481
586
  if self.encryption and encrypted_fields:
482
587
  raw = [self.encryption.decrypt_fields(r, encrypted_fields) for r in raw]
483
588
  if self._cache and use_external_cache and not no_cache:
484
589
  ttl = cache_ttl or getattr(meta, "cache_ttl", 300)
485
590
  try:
486
- self._cache.set(name, query or {}, raw, ttl)
591
+ self._cache.set(name, cache_key_query, raw, ttl)
487
592
  except Exception as _ce:
488
593
  logger.warning("Cache set failed (non-fatal): %s", _ce)
489
594
  return raw
490
595
 
491
596
  if self._cache and use_external_cache and not no_cache:
492
- cached = self._cache.get(name, query or {})
597
+ cached = self._cache.get(name, cache_key_query)
493
598
  if cached is not None:
494
599
  return cached
495
600
 
@@ -654,9 +759,12 @@ class DatabaseFactory:
654
759
  with monitor as m:
655
760
  result = self._run(_op)
656
761
  m.rows_affected = 1
657
- return result
658
- return self._run(_op)
659
- except Exception:
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))
660
768
  raise
661
769
 
662
770
  # ──────────────────────────────────────────────────────────
@@ -714,9 +822,12 @@ class DatabaseFactory:
714
822
  with monitor as m:
715
823
  result = self._run(_op)
716
824
  m.rows_affected = 1
717
- return result
718
- return self._run(_op)
719
- except Exception:
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))
720
831
  raise
721
832
 
722
833
  # ──────────────────────────────────────────────────────────
@@ -827,9 +938,12 @@ class DatabaseFactory:
827
938
  with monitor as m:
828
939
  result = self._run(_op)
829
940
  m.rows_affected = 1
830
- return result
831
- return self._run(_op)
832
- except Exception:
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))
833
947
  raise
834
948
 
835
949
  # ──────────────────────────────────────────────────────────
@@ -883,8 +997,12 @@ class DatabaseFactory:
883
997
  name = _model_name(model)
884
998
  meta = _extract_meta(model)
885
999
 
1000
+ drop_deleted = False
886
1001
  if self._soft_delete and not include_deleted:
887
- query = self._apply_soft_delete_filter(query)
1002
+ if self._is_sql(meta, engine_override):
1003
+ query = self._apply_soft_delete_filter(query)
1004
+ else:
1005
+ drop_deleted = True
888
1006
 
889
1007
  adapters = self._adapters_for(model, meta, engine_override)
890
1008
  encrypted_fields = getattr(meta, "encrypted_fields", [])
@@ -901,6 +1019,8 @@ class DatabaseFactory:
901
1019
  else type(name, (), {"__polydb__": meta.__dict__})
902
1020
  )
903
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")]
904
1024
  if self.encryption and encrypted_fields:
905
1025
  raw = [self.encryption.decrypt_fields(r, encrypted_fields) for r in raw]
906
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