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.
Files changed (127) hide show
  1. {altcodepro_polydb_python-2.5.19/src/altcodepro_polydb_python.egg-info → altcodepro_polydb_python-2.5.20}/PKG-INFO +1 -1
  2. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/pyproject.toml +1 -1
  3. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20/src/altcodepro_polydb_python.egg-info}/PKG-INFO +1 -1
  4. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/altcodepro_polydb_python.egg-info/SOURCES.txt +1 -0
  5. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/__init__.py +1 -1
  6. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/DynamoDBAdapter.py +7 -0
  7. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/base/NoSQLKVAdapter.py +3 -1
  8. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/databaseFactory.py +117 -20
  9. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/schema.py +41 -4
  10. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/types.py +2 -0
  11. altcodepro_polydb_python-2.5.20/tests/test_factory_wiring.py +247 -0
  12. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/LICENSE +0 -0
  13. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/MANIFEST.in +0 -0
  14. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/README.md +0 -0
  15. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/requirements-aws.txt +0 -0
  16. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/requirements-azure.txt +0 -0
  17. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/requirements-dev.txt +0 -0
  18. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/requirements-gcp.txt +0 -0
  19. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/requirements-generic.txt +0 -0
  20. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/requirements.txt +0 -0
  21. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/setup.cfg +0 -0
  22. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/setup.py +0 -0
  23. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/altcodepro_polydb_python.egg-info/dependency_links.txt +0 -0
  24. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/altcodepro_polydb_python.egg-info/requires.txt +0 -0
  25. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/altcodepro_polydb_python.egg-info/top_level.txt +0 -0
  26. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/PolyDB.py +0 -0
  27. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/AWSSecretsManagerAdapter.py +0 -0
  28. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/AzureBlobStorageAdapter.py +0 -0
  29. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/AzureFileStorageAdapter.py +0 -0
  30. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/AzureKeyVaultAdapter.py +0 -0
  31. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/AzureQueueAdapter.py +0 -0
  32. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/AzureTableStorageAdapter.py +0 -0
  33. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/BlockchainBlobAdapter.py +0 -0
  34. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/BlockchainFileAdapter.py +0 -0
  35. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/BlockchainKVAdapter.py +0 -0
  36. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/BlockchainQueueAdapter.py +0 -0
  37. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/EFSAdapter.py +0 -0
  38. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/FirestoreAdapter.py +0 -0
  39. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/GCPFilestoreAdapter.py +0 -0
  40. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/GCPPubSubAdapter.py +0 -0
  41. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/GCPSecretManagerAdapter.py +0 -0
  42. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/GCPStorageAdapter.py +0 -0
  43. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/KafkaQueueAdapter.py +0 -0
  44. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/MongoDBAdapter.py +0 -0
  45. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/PostgreSQLAdapter.py +0 -0
  46. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/RabbitMQAdapter.py +0 -0
  47. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/S3Adapter.py +0 -0
  48. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/S3CompatibleAdapter.py +0 -0
  49. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/SQSAdapter.py +0 -0
  50. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/VaultAdapter.py +0 -0
  51. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/VercelBlobAdapter.py +0 -0
  52. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/VercelFileAdapter.py +0 -0
  53. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/VercelKVAdapter.py +0 -0
  54. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/VercelQueueAdapter.py +0 -0
  55. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/adapters/__init__.py +0 -0
  56. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/advanced_query.py +0 -0
  57. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/aio.py +0 -0
  58. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/audit/AuditStorage.py +0 -0
  59. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/audit/__init__.py +0 -0
  60. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/audit/context.py +0 -0
  61. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/audit/manager.py +0 -0
  62. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/audit/models.py +0 -0
  63. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/base/ObjectStorageAdapter.py +0 -0
  64. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/base/QueueAdapter.py +0 -0
  65. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/base/SecretsAdapter.py +0 -0
  66. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/base/SharedFilesAdapter.py +0 -0
  67. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/base/__init__.py +0 -0
  68. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/batch.py +0 -0
  69. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/cache.py +0 -0
  70. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/cloudDatabaseFactory.py +0 -0
  71. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/errors.py +0 -0
  72. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/json_safe.py +0 -0
  73. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/models.py +0 -0
  74. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/monitoring.py +0 -0
  75. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/multitenancy.py +0 -0
  76. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/observability/__init__.py +0 -0
  77. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/observability/logging.py +0 -0
  78. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/overflow_gc.py +0 -0
  79. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/py.typed +0 -0
  80. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/query.py +0 -0
  81. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/registry.py +0 -0
  82. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/retry.py +0 -0
  83. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/security.py +0 -0
  84. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/services/__init__.py +0 -0
  85. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/services/compliance_service.py +0 -0
  86. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/services/security_service.py +0 -0
  87. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/utils.py +0 -0
  88. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/src/polydb/validation.py +0 -0
  89. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_advanced_query_injection.py +0 -0
  90. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_async_api.py +0 -0
  91. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_atomic_decrement.py +0 -0
  92. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_audit_chain_cutover.py +0 -0
  93. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_audit_hmac.py +0 -0
  94. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_aws.py +0 -0
  95. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_azure.py +0 -0
  96. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_azure_queue_receipt_handle.py +0 -0
  97. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_azure_table_empty_filter.py +0 -0
  98. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_azure_table_id_property_persisted.py +0 -0
  99. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_azure_table_id_return_value_sanitization_mismatch.py +0 -0
  100. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_azure_table_large_record_patch_roundtrip.py +0 -0
  101. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_azure_table_unpack_entity.py +0 -0
  102. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_blockchain.py +0 -0
  103. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_blockchain_overflow_guard.py +0 -0
  104. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_cloud_factory.py +0 -0
  105. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_field_encryption.py +0 -0
  106. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_gcp.py +0 -0
  107. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_kafka.py +0 -0
  108. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_like_pattern_escaping.py +0 -0
  109. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_mongodb.py +0 -0
  110. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_monitoring_health.py +0 -0
  111. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_multi_engine.py +0 -0
  112. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_multitenancy_ddl_injection.py +0 -0
  113. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_nosql_kv_overflow_write_path.py +0 -0
  114. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_nosql_operator_injection.py +0 -0
  115. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_observability.py +0 -0
  116. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_overflow_gc.py +0 -0
  117. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_postgresql.py +0 -0
  118. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_postgresql_deserialize_row.py +0 -0
  119. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_queue_extend_delay_cancel.py +0 -0
  120. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_rabbitmq.py +0 -0
  121. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_read_projection.py +0 -0
  122. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_redis_kv.py +0 -0
  123. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_schema_ddl_injection.py +0 -0
  124. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_secrets_aws.py +0 -0
  125. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_secrets_vault.py +0 -0
  126. {altcodepro_polydb_python-2.5.19 → altcodepro_polydb_python-2.5.20}/tests/test_session_vars.py +0 -0
  127. {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.19
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.19"
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.19
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.18"
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
- 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:]
295
297
 
296
298
  has_more = len(results) > page_size
297
299
  if has_more:
@@ -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
- 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
  # ──────────────────────────────────────────────────────────
@@ -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
- 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
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, no_cache=no_cache or bool(use_external_cache),
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, query or {}, raw, ttl)
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, query or {})
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
- return result
681
- return self._run(_op)
682
- 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))
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
- return result
741
- return self._run(_op)
742
- 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))
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
- return result
854
- return self._run(_op)
855
- 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))
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
- 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
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;"]