altcodepro-polydb-python 2.5.12__tar.gz → 2.5.18__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 (125) hide show
  1. {altcodepro_polydb_python-2.5.12/src/altcodepro_polydb_python.egg-info → altcodepro_polydb_python-2.5.18}/PKG-INFO +1 -1
  2. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/pyproject.toml +1 -1
  3. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18/src/altcodepro_polydb_python.egg-info}/PKG-INFO +1 -1
  4. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/altcodepro_polydb_python.egg-info/SOURCES.txt +2 -0
  5. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/__init__.py +1 -1
  6. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/adapters/AzureTableStorageAdapter.py +13 -0
  7. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/adapters/PostgreSQLAdapter.py +44 -0
  8. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/schema.py +19 -1
  9. altcodepro_polydb_python-2.5.18/tests/test_azure_table_large_record_patch_roundtrip.py +118 -0
  10. altcodepro_polydb_python-2.5.18/tests/test_postgresql_deserialize_row.py +120 -0
  11. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/tests/test_schema_ddl_injection.py +30 -0
  12. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/LICENSE +0 -0
  13. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/MANIFEST.in +0 -0
  14. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/README.md +0 -0
  15. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/requirements-aws.txt +0 -0
  16. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/requirements-azure.txt +0 -0
  17. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/requirements-dev.txt +0 -0
  18. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/requirements-gcp.txt +0 -0
  19. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/requirements-generic.txt +0 -0
  20. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/requirements.txt +0 -0
  21. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/setup.cfg +0 -0
  22. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/setup.py +0 -0
  23. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/altcodepro_polydb_python.egg-info/dependency_links.txt +0 -0
  24. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/altcodepro_polydb_python.egg-info/requires.txt +0 -0
  25. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/altcodepro_polydb_python.egg-info/top_level.txt +0 -0
  26. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/PolyDB.py +0 -0
  27. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/adapters/AWSSecretsManagerAdapter.py +0 -0
  28. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/adapters/AzureBlobStorageAdapter.py +0 -0
  29. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/adapters/AzureFileStorageAdapter.py +0 -0
  30. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/adapters/AzureKeyVaultAdapter.py +0 -0
  31. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/adapters/AzureQueueAdapter.py +0 -0
  32. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/adapters/BlockchainBlobAdapter.py +0 -0
  33. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/adapters/BlockchainFileAdapter.py +0 -0
  34. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/adapters/BlockchainKVAdapter.py +0 -0
  35. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/adapters/BlockchainQueueAdapter.py +0 -0
  36. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/adapters/DynamoDBAdapter.py +0 -0
  37. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/adapters/EFSAdapter.py +0 -0
  38. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/adapters/FirestoreAdapter.py +0 -0
  39. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/adapters/GCPFilestoreAdapter.py +0 -0
  40. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/adapters/GCPPubSubAdapter.py +0 -0
  41. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/adapters/GCPSecretManagerAdapter.py +0 -0
  42. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/adapters/GCPStorageAdapter.py +0 -0
  43. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/adapters/KafkaQueueAdapter.py +0 -0
  44. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/adapters/MongoDBAdapter.py +0 -0
  45. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/adapters/RabbitMQAdapter.py +0 -0
  46. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/adapters/S3Adapter.py +0 -0
  47. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/adapters/S3CompatibleAdapter.py +0 -0
  48. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/adapters/SQSAdapter.py +0 -0
  49. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/adapters/VaultAdapter.py +0 -0
  50. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/adapters/VercelBlobAdapter.py +0 -0
  51. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/adapters/VercelFileAdapter.py +0 -0
  52. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/adapters/VercelKVAdapter.py +0 -0
  53. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/adapters/VercelQueueAdapter.py +0 -0
  54. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/adapters/__init__.py +0 -0
  55. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/advanced_query.py +0 -0
  56. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/aio.py +0 -0
  57. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/audit/AuditStorage.py +0 -0
  58. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/audit/__init__.py +0 -0
  59. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/audit/context.py +0 -0
  60. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/audit/manager.py +0 -0
  61. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/audit/models.py +0 -0
  62. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/base/NoSQLKVAdapter.py +0 -0
  63. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/base/ObjectStorageAdapter.py +0 -0
  64. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/base/QueueAdapter.py +0 -0
  65. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/base/SecretsAdapter.py +0 -0
  66. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/base/SharedFilesAdapter.py +0 -0
  67. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/base/__init__.py +0 -0
  68. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/batch.py +0 -0
  69. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/cache.py +0 -0
  70. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/cloudDatabaseFactory.py +0 -0
  71. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/databaseFactory.py +0 -0
  72. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/errors.py +0 -0
  73. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/json_safe.py +0 -0
  74. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/models.py +0 -0
  75. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/monitoring.py +0 -0
  76. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/multitenancy.py +0 -0
  77. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/observability/__init__.py +0 -0
  78. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/observability/logging.py +0 -0
  79. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/overflow_gc.py +0 -0
  80. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/py.typed +0 -0
  81. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/query.py +0 -0
  82. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/registry.py +0 -0
  83. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/retry.py +0 -0
  84. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/security.py +0 -0
  85. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/services/__init__.py +0 -0
  86. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/services/compliance_service.py +0 -0
  87. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/services/security_service.py +0 -0
  88. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/types.py +0 -0
  89. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/utils.py +0 -0
  90. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/src/polydb/validation.py +0 -0
  91. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/tests/test_advanced_query_injection.py +0 -0
  92. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/tests/test_async_api.py +0 -0
  93. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/tests/test_atomic_decrement.py +0 -0
  94. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/tests/test_audit_chain_cutover.py +0 -0
  95. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/tests/test_audit_hmac.py +0 -0
  96. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/tests/test_aws.py +0 -0
  97. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/tests/test_azure.py +0 -0
  98. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/tests/test_azure_queue_receipt_handle.py +0 -0
  99. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/tests/test_azure_table_empty_filter.py +0 -0
  100. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/tests/test_azure_table_id_property_persisted.py +0 -0
  101. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/tests/test_azure_table_id_return_value_sanitization_mismatch.py +0 -0
  102. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/tests/test_azure_table_unpack_entity.py +0 -0
  103. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/tests/test_blockchain.py +0 -0
  104. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/tests/test_blockchain_overflow_guard.py +0 -0
  105. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/tests/test_cloud_factory.py +0 -0
  106. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/tests/test_field_encryption.py +0 -0
  107. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/tests/test_gcp.py +0 -0
  108. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/tests/test_kafka.py +0 -0
  109. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/tests/test_like_pattern_escaping.py +0 -0
  110. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/tests/test_mongodb.py +0 -0
  111. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/tests/test_monitoring_health.py +0 -0
  112. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/tests/test_multi_engine.py +0 -0
  113. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/tests/test_multitenancy_ddl_injection.py +0 -0
  114. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/tests/test_nosql_kv_overflow_write_path.py +0 -0
  115. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/tests/test_nosql_operator_injection.py +0 -0
  116. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/tests/test_observability.py +0 -0
  117. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/tests/test_overflow_gc.py +0 -0
  118. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/tests/test_postgresql.py +0 -0
  119. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/tests/test_queue_extend_delay_cancel.py +0 -0
  120. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/tests/test_rabbitmq.py +0 -0
  121. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/tests/test_redis_kv.py +0 -0
  122. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/tests/test_secrets_aws.py +0 -0
  123. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/tests/test_secrets_vault.py +0 -0
  124. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/tests/test_session_vars.py +0 -0
  125. {altcodepro_polydb_python-2.5.12 → altcodepro_polydb_python-2.5.18}/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.12
3
+ Version: 2.5.18
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.12"
7
+ version = "2.5.18"
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.12
3
+ Version: 2.5.18
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
@@ -94,6 +94,7 @@ tests/test_azure_queue_receipt_handle.py
94
94
  tests/test_azure_table_empty_filter.py
95
95
  tests/test_azure_table_id_property_persisted.py
96
96
  tests/test_azure_table_id_return_value_sanitization_mismatch.py
97
+ tests/test_azure_table_large_record_patch_roundtrip.py
97
98
  tests/test_azure_table_unpack_entity.py
98
99
  tests/test_blockchain.py
99
100
  tests/test_blockchain_overflow_guard.py
@@ -111,6 +112,7 @@ tests/test_nosql_operator_injection.py
111
112
  tests/test_observability.py
112
113
  tests/test_overflow_gc.py
113
114
  tests/test_postgresql.py
115
+ tests/test_postgresql_deserialize_row.py
114
116
  tests/test_queue_extend_delay_cancel.py
115
117
  tests/test_rabbitmq.py
116
118
  tests/test_redis_kv.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.12"
7
+ __version__ = "2.5.18"
8
8
 
9
9
  from .aio import AsyncDatabaseFactory, AsyncPolyDB
10
10
  from .audit.context import AuditContext
@@ -125,6 +125,19 @@ class AzureTableStorageAdapter(NoSQLKVAdapter):
125
125
  s = re.sub(r"[\\/#\?\x00-\x1f\x7f:+ ]", "_", s)
126
126
  return s[:1024] if len(s) > 1024 else s
127
127
 
128
+ def _check_overflow(self, data: JsonDict): # type: ignore[override]
129
+ """Azure overflows per PROPERTY inside `_put_raw`, never per record.
130
+
131
+ The base class' whole-record overflow replaces the record with a stub
132
+ of underscore keys (`_overflow`, `_blob_key`, ...), but `_put_raw`
133
+ drops every key starting with "_" when it builds the entity -- so the
134
+ stub's pointer was never stored and the real fields of the write were
135
+ silently lost (only an orphaned blob remained). Returning the data
136
+ untouched lets `_put_raw` spill each >30KB property on its own, which
137
+ `_get_raw`/`_query_raw` already rehydrate.
138
+ """
139
+ return data, None
140
+
128
141
  def _sanitize_prop_name(self, name: Any) -> str:
129
142
  s = str(name)
130
143
  s = re.sub(r"[^A-Za-z0-9_]", "_", s)
@@ -365,6 +365,50 @@ class PostgreSQLAdapter:
365
365
  return [self._serialize_param(p) for p in params]
366
366
 
367
367
  def _deserialize_row(self, row: JsonDict) -> JsonDict:
368
+ """Best-effort convenience decode: any string column value that
369
+ LOOKS like JSON (starts/ends with `{}`/`[]`) is `json.loads`'d,
370
+ regardless of the column's own declared type. This is a real,
371
+ deliberate, LOAD-BEARING convenience a real caller depends on --
372
+ e.g. universal-interprter's `observability.py`'s `Observability.
373
+ query()` writes a `logs.emit` payload as `json.dumps(...)` into a
374
+ plain TEXT column (`payload`, never JSONB -- see that module's
375
+ own comment) and its own docstring explicitly promises "logs.
376
+ query's own `payload` field gets a real dict back"; removing this
377
+ heuristic (briefly tried, then reverted -- see this repo's own
378
+ 2.5.16/2.5.17 CLAUDE.md history) silently broke that real,
379
+ already-shipped contract. `query_log.py`'s own `fields` column is
380
+ the identical shape, just defended a second way: it independently
381
+ `json.loads`'s its own value with an `isinstance(..., str)` guard
382
+ rather than relying on this method at all -- proof this repo has
383
+ real callers on both sides (some lean on this adapter-level
384
+ decode, some defend for themselves).
385
+
386
+ **The real, honest trade-off, unavoidable at this layer**: a
387
+ genuinely non-JSON TEXT/VARCHAR string whose own real content
388
+ merely LOOKS JSON-shaped (e.g. a prompt template's own `content`
389
+ column holding a literal JSON-encoded email template -- see
390
+ universal-interprter's `prompt_template_store.py`) gets coerced
391
+ from `str` to `dict`/`list` here too, since this method has no
392
+ way to distinguish "real app data serialized as JSON text" from
393
+ "a legitimately JSON-shaped string that must stay a string" --
394
+ both are simply "a string that starts with `{` and ends with
395
+ `}`." This adapter deliberately does NOT try to solve that
396
+ ambiguity generically (declaring the column JSONB instead sidesteps
397
+ it entirely -- psycopg2 registers global JSON/JSONB typecasters
398
+ for OIDs 114/3802 automatically at import time, so a genuine
399
+ `json`/`jsonb` column never even reaches this heuristic, `isinstance
400
+ (v, str)` already `False` by the time this method sees it); a
401
+ caller that must keep a TEXT column's string content literal
402
+ despite it being JSON-shaped owns that coercion at ITS OWN layer
403
+ instead (see `prompt_template_store.py`'s own `_coerce_content_to_str`
404
+ for the concrete pattern: re-`json.dumps()` a `content` value that
405
+ came back non-`str`, restoring the real string every caller of
406
+ that store is promised). This adapter stays a "dumb storage
407
+ layer" in the sense that it never MODELS what a column's real
408
+ semantic type should be -- it does still apply this one generic,
409
+ best-effort convenience decode uniformly, exactly as it always
410
+ has.
411
+ """
368
412
  for k, v in list(row.items()):
369
413
  if isinstance(v, str):
370
414
  s = v.strip()
@@ -61,6 +61,17 @@ class Index:
61
61
  name: str
62
62
  columns: List[str]
63
63
  unique: bool = False
64
+ # Postgres index access method -- "btree" (the default, and the only
65
+ # one Postgres actually permits combined with UNIQUE) plus the real
66
+ # non-default methods a caller may need for a specific column shape:
67
+ # "gin"/"gist" (full-text search, JSONB containment, array overlap),
68
+ # "hash" (equality-only, smaller than btree for that one case), "brin"
69
+ # (large, naturally-ordered, append-mostly columns -- cheap to
70
+ # maintain relative to btree at real scale). Never validated against
71
+ # Postgres's own real per-type support here -- that's a live DDL
72
+ # concern the caller's own database will enforce; this dataclass only
73
+ # carries the caller's choice through to the generated statement.
74
+ using: str = "btree"
64
75
 
65
76
 
66
77
  class SchemaBuilder:
@@ -128,10 +139,17 @@ class SchemaBuilder:
128
139
 
129
140
  for idx in self.indexes:
130
141
  unique = "UNIQUE " if idx.unique else ""
142
+ # "btree" omitted outright rather than spelled out as
143
+ # "USING btree" -- it's Postgres's own implicit default, and
144
+ # every index this codebase generated before `using` existed
145
+ # rendered without a USING clause at all; emitting it only for
146
+ # a non-default method keeps every pre-existing caller's own
147
+ # generated SQL text byte-for-byte unchanged.
148
+ using = f"USING {idx.using} " if idx.using != "btree" else ""
131
149
  cols = ", ".join(validate_column_name(c) for c in idx.columns)
132
150
  sql = (
133
151
  f"CREATE {unique}INDEX IF NOT EXISTS {validate_table_name(idx.name)} "
134
- f"ON {table_name}({cols});"
152
+ f"ON {table_name}{' ' if using else ''}{using}({cols});"
135
153
  )
136
154
  statements.append(sql)
137
155
 
@@ -0,0 +1,118 @@
1
+ """Unit tests (no Azurite needed) for a real, reproduced data-loss bug.
2
+
3
+ `NoSQLKVAdapter.put()/patch()` run `_check_overflow()` on the WHOLE record
4
+ before calling `_put_raw()`. `AzureTableStorageAdapter` sets `max_size` to
5
+ 60KB (it has its own per-PROPERTY overflow for strings > 30KB inside
6
+ `_put_raw`), so any record above 60KB was replaced by a whole-record stub
7
+ `{_overflow, _blob_key, _size, _checksum}`. `_put_raw` then skips every key
8
+ starting with "_" when it builds the entity, so the stub's pointer was never
9
+ stored and the update's real fields (content_url, version, ...) were never
10
+ written -- a silent drop that left only an orphaned blob. Seen live: a
11
+ ~41KB generated document plus its prompt context pushed an `artifacts` row
12
+ over 60KB; `core.db.update` reported success but the re-read row had none
13
+ of the fields.
14
+ """
15
+
16
+ import threading
17
+
18
+ import pytest
19
+
20
+ pytest.importorskip("azure.data.tables")
21
+
22
+ from polydb.adapters.AzureTableStorageAdapter import AzureTableStorageAdapter # noqa: E402
23
+
24
+
25
+ class _Model:
26
+ __name__ = "artifacts"
27
+ __qualname__ = "artifacts"
28
+ __polydb__ = {"pk_field": "project_slug", "rk_field": "artifact_id"}
29
+
30
+
31
+ class _FakeTableClient:
32
+ def __init__(self):
33
+ self.store = {}
34
+
35
+ def upsert_entity(self, entity, **kw):
36
+ key = (entity["PartitionKey"], entity["RowKey"])
37
+ merged = dict(self.store.get(key, {}))
38
+ merged.update(entity) # azure MERGE semantics
39
+ self.store[key] = merged
40
+
41
+ def get_entity(self, pk, rk):
42
+ return dict(self.store[(pk, rk)])
43
+
44
+
45
+ def _adapter():
46
+ fake = _FakeTableClient()
47
+ blobs = {}
48
+ adp = object.__new__(AzureTableStorageAdapter)
49
+ adp.max_size = AzureTableStorageAdapter.AZURE_TABLE_MAX_SIZE
50
+ adp.partition_config = None
51
+ adp._lock = threading.Lock()
52
+
53
+ class _ObjStore: # what the base class' whole-record overflow writes to
54
+ def put(self, key, data):
55
+ blobs[key] = data
56
+
57
+ def get(self, key):
58
+ return blobs[key]
59
+
60
+ adp.object_storage = _ObjStore()
61
+ adp._blob_service = None
62
+ adp._get_table_client = lambda model: fake
63
+ adp._blob_upload = lambda key, data: blobs.__setitem__(key, data)
64
+ adp._blob_download = lambda key: blobs[key]
65
+ return adp, fake, blobs
66
+
67
+
68
+ def _row(**extra):
69
+ base = {"project_slug": "p-1", "artifact_id": "a-1", "status": "generating", "version": 0}
70
+ base.update(extra)
71
+ return base
72
+
73
+
74
+ def test_patch_of_a_record_over_60kb_keeps_every_field():
75
+ adp, fake, blobs = _adapter()
76
+ adp.put(_Model, _row())
77
+
78
+ big = "x" * (45 * 1024)
79
+ adp.patch(
80
+ _Model,
81
+ {"pk": "p-1", "rk": "a-1"},
82
+ {
83
+ "content": big,
84
+ "system_prompt": "y" * (40 * 1024),
85
+ "content_url": "https://example/blob/1",
86
+ "content_blob_key": "blob-1",
87
+ "version": 1,
88
+ "preview_text": "# Title...",
89
+ },
90
+ )
91
+
92
+ row = adp._get_raw(_Model, "p-1", "a-1")
93
+ assert row["content"] == big
94
+ assert row["content_url"] == "https://example/blob/1"
95
+ assert row["content_blob_key"] == "blob-1"
96
+ assert row["version"] == 1
97
+ assert row["preview_text"] == "# Title..."
98
+ assert row["status"] == "generating"
99
+
100
+
101
+ def test_later_small_patch_does_not_lose_the_large_fields():
102
+ adp, fake, blobs = _adapter()
103
+ adp.put(_Model, _row())
104
+ big = "z" * (50 * 1024)
105
+ adp.patch(_Model, {"pk": "p-1", "rk": "a-1"}, {"content": big, "system_prompt": "q" * 50000})
106
+ adp.patch(_Model, {"pk": "p-1", "rk": "a-1"}, {"status": "validating"})
107
+
108
+ row = adp._get_raw(_Model, "p-1", "a-1")
109
+ assert row["status"] == "validating"
110
+ assert row["content"] == big
111
+
112
+
113
+ def test_no_whole_record_stub_is_ever_written_for_azure():
114
+ adp, fake, blobs = _adapter()
115
+ adp.put(_Model, _row(content="w" * (70 * 1024)))
116
+ stored = fake.store[("p-1", "a-1")]
117
+ assert "_overflow" not in stored and not any(k.startswith("overflow/") for k in blobs)
118
+ assert stored["status"] == "generating"
@@ -0,0 +1,120 @@
1
+ """PostgreSQLAdapter._deserialize_row's best-effort JSON-shaped-string
2
+ decode -- real, load-bearing behavior a real caller depends on, plus the
3
+ honest trade-off it can't avoid.
4
+
5
+ History: this heuristic (`json.loads()` any string column value that
6
+ starts/ends with `{}`/`[]`, regardless of the column's own declared
7
+ type) was briefly REMOVED in 2.5.16 after a real, reproduced bug --
8
+ universal-interprter's `prompt_template_store.py` stores an email
9
+ template's `content` as a plain TEXT column holding a JSON-encoded
10
+ string, and reading it back silently coerced it from `str` to `dict`,
11
+ crashing `prompt.render` -> `text.template`'s `template.replace(...)`
12
+ with `'dict' object has no attribute 'replace'`. That removal was itself
13
+ a real regression, reverted in 2.5.17: universal-interprter's own
14
+ `observability.py` (`Observability.query()`) writes a `logs.emit`
15
+ payload as `json.dumps(...)` into a plain TEXT column (`payload`, never
16
+ JSONB) and its own docstring explicitly promises "logs.query's own
17
+ `payload` field gets a real dict back" -- a real, already-shipped
18
+ contract this heuristic is the only thing that ever fulfilled, since
19
+ that read path is a raw `sql_adapter.execute()` call with no explicit
20
+ parsing of its own (unlike `query_log.py`'s own `fields` column, the
21
+ identical shape, which defends itself with an `isinstance(..., str)`
22
+ guard + its own `json.loads()` rather than relying on this method).
23
+
24
+ The real, honest trade-off (unavoidable at this layer, see
25
+ _deserialize_row's own docstring): a TEXT column can never be told apart
26
+ from a JSONB one purely by string-sniffing its OWN value's shape, so a
27
+ caller that must keep a TEXT column's real string content literal
28
+ despite it looking JSON-shaped (like `prompt_template_store.py`'s
29
+ `content`) owns that coercion at ITS OWN layer -- see that module's own
30
+ `_coerce_content_to_str` in universal-interprter for the concrete
31
+ pattern. No live Postgres connection is needed for any of this --
32
+ `_deserialize_row` is a pure dict-in/dict-out method.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from polydb.adapters.PostgreSQLAdapter import PostgreSQLAdapter
38
+
39
+
40
+ def _adapter() -> PostgreSQLAdapter:
41
+ # __init__ eagerly opens a real connection pool (no lazy-connect mode
42
+ # exists) -- _deserialize_row touches no instance state at all, so a
43
+ # bare, uninitialized instance is enough to exercise it without a
44
+ # live Postgres.
45
+ return object.__new__(PostgreSQLAdapter)
46
+
47
+
48
+ class TestDeserializeRowDecodesJsonShapedStrings:
49
+ def test_a_json_object_shaped_string_is_parsed_into_a_dict(self):
50
+ # The real, load-bearing case: a caller (e.g. observability.py's
51
+ # own `payload` column) wrote `json.dumps(...)` into a plain TEXT
52
+ # column and expects a real dict back on read.
53
+ adapter = _adapter()
54
+ row = {"id": "1", "payload": '{"event": "otp_sent", "channel": "email"}'}
55
+
56
+ result = adapter._deserialize_row(dict(row))
57
+
58
+ assert result["payload"] == {"event": "otp_sent", "channel": "email"}
59
+
60
+ def test_a_json_array_shaped_string_is_parsed_into_a_list(self):
61
+ adapter = _adapter()
62
+ row = {"id": "1", "fields": '["email", "status"]'}
63
+
64
+ result = adapter._deserialize_row(dict(row))
65
+
66
+ assert result["fields"] == ["email", "status"]
67
+
68
+ def test_a_string_that_merely_looks_json_shaped_but_isnt_valid_json_is_left_alone(self):
69
+ adapter = _adapter()
70
+ row = {"id": "1", "notes": "{not actually valid json}"}
71
+
72
+ result = adapter._deserialize_row(dict(row))
73
+
74
+ assert result["notes"] == row["notes"]
75
+ assert isinstance(result["notes"], str)
76
+
77
+ def test_an_ordinary_scalar_string_is_untouched(self):
78
+ adapter = _adapter()
79
+ row = {"id": "1", "name": "plain text, not json at all"}
80
+
81
+ result = adapter._deserialize_row(dict(row))
82
+
83
+ assert result == row
84
+
85
+ def test_a_real_dict_value_already_parsed_by_the_driver_passes_through_unchanged(self):
86
+ # This is what a genuine json/jsonb column already looks like by
87
+ # the time _deserialize_row runs -- psycopg2's own global
88
+ # typecasters (OIDs 114/3802) already turned it into a native
89
+ # dict, so there is nothing left for this method to do.
90
+ adapter = _adapter()
91
+ row = {"id": "1", "metadata": {"subject": "hi"}}
92
+
93
+ result = adapter._deserialize_row(dict(row))
94
+
95
+ assert result == row
96
+ assert isinstance(result["metadata"], dict)
97
+
98
+ def test_none_and_non_string_values_are_left_alone(self):
99
+ adapter = _adapter()
100
+ row = {"id": "1", "deleted_at": None, "count": 3, "active": True}
101
+
102
+ result = adapter._deserialize_row(dict(row))
103
+
104
+ assert result == row
105
+
106
+ def test_the_honest_trade_off_a_json_shaped_string_that_is_meant_to_stay_literal_text_still_gets_coerced(
107
+ self,
108
+ ):
109
+ # The real cost this heuristic imposes, documented rather than
110
+ # hidden: a caller owning a TEXT column whose real content
111
+ # happens to be JSON-shaped (e.g. prompt_template_store.py's own
112
+ # `content`) must defend against this itself -- this adapter has
113
+ # no way to know that a given TEXT value should stay a literal
114
+ # string instead of being decoded.
115
+ adapter = _adapter()
116
+ row = {"id": "1", "content": '{"subject": "hi", "html": "<p>{{code}}</p>"}'}
117
+
118
+ result = adapter._deserialize_row(dict(row))
119
+
120
+ assert isinstance(result["content"], dict) # not the literal string it was written as
@@ -140,3 +140,33 @@ class TestLegitimateSchemasStillBuild:
140
140
  assert builder.to_create_indexes("users") == [
141
141
  "CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email, id);"
142
142
  ]
143
+
144
+
145
+ class TestIndexUsingClause:
146
+ """Index.using (default "btree") -- added to let a caller pick a
147
+ non-default Postgres index access method (gin/gist/hash/brin) for a
148
+ specific column shape, without touching the default btree case's own
149
+ generated SQL text at all."""
150
+
151
+ def test_default_btree_renders_with_no_using_clause_at_all(self):
152
+ # Every Index constructed before `using` existed implicitly meant
153
+ # btree -- this proves that default still renders byte-for-byte
154
+ # identical to the pre-`using` SQL (no "USING btree" ever emitted).
155
+ builder = _builder().add_index(Index(name="idx_users_email", columns=["email"]))
156
+ assert builder.to_create_indexes("users") == [
157
+ "CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);"
158
+ ]
159
+
160
+ def test_non_default_using_is_rendered_between_table_name_and_columns(self):
161
+ builder = _builder().add_index(Index(name="idx_users_tags", columns=["tags"], using="gin"))
162
+ assert builder.to_create_indexes("users") == [
163
+ "CREATE INDEX IF NOT EXISTS idx_users_tags ON users USING gin (tags);"
164
+ ]
165
+
166
+ def test_unique_and_a_non_default_using_compose(self):
167
+ builder = _builder().add_index(
168
+ Index(name="idx_users_key", columns=["key"], unique=True, using="hash")
169
+ )
170
+ assert builder.to_create_indexes("users") == [
171
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_users_key ON users USING hash (key);"
172
+ ]