velocity-python 0.1.62__tar.gz → 0.1.66__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 (206) hide show
  1. {velocity_python-0.1.62 → velocity_python-0.1.66}/PKG-INFO +1 -1
  2. {velocity_python-0.1.62 → velocity_python-0.1.66}/pyproject.toml +1 -1
  3. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/__init__.py +1 -1
  4. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/aws/handlers/context.py +37 -0
  5. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/aws/handlers/lambda_handler.py +34 -1
  6. velocity_python-0.1.66/src/velocity/aws/handlers/masquerade.py +150 -0
  7. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/aws/handlers/mixins/web_handler.py +9 -4
  8. velocity_python-0.1.66/src/velocity/db/core/jsonproxy.py +175 -0
  9. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/core/row.py +19 -3
  10. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/core/table.py +24 -7
  11. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/servers/base/sql.py +16 -0
  12. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/servers/mysql/types.py +7 -0
  13. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/servers/postgres/sql.py +14 -0
  14. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/servers/postgres/types.py +10 -0
  15. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/servers/sqlite/sql.py +3 -3
  16. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity_python.egg-info/PKG-INFO +1 -1
  17. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity_python.egg-info/SOURCES.txt +5 -0
  18. velocity_python-0.1.66/tests/test_json_columns.py +305 -0
  19. velocity_python-0.1.66/tests/test_lambda_handler_masquerade.py +158 -0
  20. velocity_python-0.1.66/tests/test_masquerade_grant.py +111 -0
  21. {velocity_python-0.1.62 → velocity_python-0.1.66}/LICENSE +0 -0
  22. {velocity_python-0.1.62 → velocity_python-0.1.66}/README.md +0 -0
  23. {velocity_python-0.1.62 → velocity_python-0.1.66}/setup.cfg +0 -0
  24. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/aws/__init__.py +0 -0
  25. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/aws/amplify.py +0 -0
  26. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/aws/amplify_build.py +0 -0
  27. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/aws/assets/__init__.py +0 -0
  28. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/aws/assets/backfill.py +0 -0
  29. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/aws/assets/indexing.py +0 -0
  30. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/aws/assets/references.py +0 -0
  31. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/aws/assets/service.py +0 -0
  32. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/aws/assets/usage_index.py +0 -0
  33. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/aws/dirty_pipeline.py +0 -0
  34. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/aws/handlers/__init__.py +0 -0
  35. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/aws/handlers/base_handler.py +0 -0
  36. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/aws/handlers/context_factory.py +0 -0
  37. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/aws/handlers/exceptions.py +0 -0
  38. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/aws/handlers/mixins/__init__.py +0 -0
  39. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/aws/handlers/mixins/data_service.py +0 -0
  40. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/aws/handlers/perf.py +0 -0
  41. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/aws/handlers/response.py +0 -0
  42. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/aws/handlers/sqs_handler.py +0 -0
  43. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/aws/s3.py +0 -0
  44. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/aws/ssm_config.py +0 -0
  45. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/aws/tests/__init__.py +0 -0
  46. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/aws/tests/test_base_handler_error_response.py +0 -0
  47. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/aws/tests/test_lambda_handler_json_serialization.py +0 -0
  48. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/aws/tests/test_response.py +0 -0
  49. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/__init__.py +0 -0
  50. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/core/__init__.py +0 -0
  51. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/core/async_support.py +0 -0
  52. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/core/column.py +0 -0
  53. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/core/database.py +0 -0
  54. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/core/decorators.py +0 -0
  55. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/core/engine.py +0 -0
  56. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/core/result.py +0 -0
  57. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/core/sequence.py +0 -0
  58. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/core/transaction.py +0 -0
  59. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/core/view.py +0 -0
  60. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/exceptions.py +0 -0
  61. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/migrations.py +0 -0
  62. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/servers/__init__.py +0 -0
  63. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/servers/base/__init__.py +0 -0
  64. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/servers/base/initializer.py +0 -0
  65. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/servers/base/operators.py +0 -0
  66. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/servers/base/types.py +0 -0
  67. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/servers/mysql/__init__.py +0 -0
  68. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/servers/mysql/operators.py +0 -0
  69. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/servers/mysql/reserved.py +0 -0
  70. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/servers/mysql/sql.py +0 -0
  71. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/servers/postgres/__init__.py +0 -0
  72. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/servers/postgres/operators.py +0 -0
  73. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/servers/postgres/reserved.py +0 -0
  74. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/servers/sqlite/__init__.py +0 -0
  75. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/servers/sqlite/operators.py +0 -0
  76. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/servers/sqlite/reserved.py +0 -0
  77. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/servers/sqlite/types.py +0 -0
  78. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/servers/sqlserver/__init__.py +0 -0
  79. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/servers/sqlserver/operators.py +0 -0
  80. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/servers/sqlserver/reserved.py +0 -0
  81. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/servers/sqlserver/sql.py +0 -0
  82. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/servers/sqlserver/types.py +0 -0
  83. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/servers/tablehelper.py +0 -0
  84. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/__init__.py +0 -0
  85. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/common_db_test.py +0 -0
  86. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/postgres/__init__.py +0 -0
  87. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/postgres/common.py +0 -0
  88. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/postgres/conftest.py +0 -0
  89. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/postgres/test_column.py +0 -0
  90. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/postgres/test_connections.py +0 -0
  91. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/postgres/test_database.py +0 -0
  92. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/postgres/test_engine.py +0 -0
  93. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/postgres/test_general_usage.py +0 -0
  94. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/postgres/test_imports.py +0 -0
  95. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/postgres/test_result.py +0 -0
  96. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/postgres/test_row.py +0 -0
  97. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/postgres/test_row_comprehensive.py +0 -0
  98. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/postgres/test_schema_locking.py +0 -0
  99. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/postgres/test_schema_locking_unit.py +0 -0
  100. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/postgres/test_sequence.py +0 -0
  101. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/postgres/test_sql_comprehensive.py +0 -0
  102. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/postgres/test_table.py +0 -0
  103. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/postgres/test_table_comprehensive.py +0 -0
  104. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/postgres/test_transaction.py +0 -0
  105. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/sql/__init__.py +0 -0
  106. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/sql/common.py +0 -0
  107. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/sql/test_postgres_select_advanced.py +0 -0
  108. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/sql/test_postgres_select_variances.py +0 -0
  109. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/test_cursor_rowcount_fix.py +0 -0
  110. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/test_db_utils.py +0 -0
  111. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/test_postgres.py +0 -0
  112. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/test_postgres_unchanged.py +0 -0
  113. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/test_process_error_robustness.py +0 -0
  114. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/test_result_caching.py +0 -0
  115. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/test_result_sql_aware.py +0 -0
  116. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/test_row_get_missing_column.py +0 -0
  117. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/test_schema_locking_initializers.py +0 -0
  118. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/test_schema_locking_simple.py +0 -0
  119. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/test_sql_builder.py +0 -0
  120. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/test_tablehelper.py +0 -0
  121. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/tests/test_view_helper.py +0 -0
  122. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/db/utils.py +0 -0
  123. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/logging.py +0 -0
  124. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/misc/__init__.py +0 -0
  125. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/misc/conv/__init__.py +0 -0
  126. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/misc/conv/iconv.py +0 -0
  127. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/misc/conv/oconv.py +0 -0
  128. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/misc/db.py +0 -0
  129. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/misc/export.py +0 -0
  130. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/misc/format.py +0 -0
  131. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/misc/mail.py +0 -0
  132. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/misc/merge.py +0 -0
  133. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/misc/pdf.py +0 -0
  134. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/misc/tests/__init__.py +0 -0
  135. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/misc/tests/test_db.py +0 -0
  136. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/misc/tests/test_fix.py +0 -0
  137. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/misc/tests/test_format.py +0 -0
  138. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/misc/tests/test_iconv.py +0 -0
  139. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/misc/tests/test_merge.py +0 -0
  140. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/misc/tests/test_oconv.py +0 -0
  141. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/misc/tests/test_original_error.py +0 -0
  142. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/misc/tests/test_timer.py +0 -0
  143. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/misc/timer.py +0 -0
  144. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/misc/tools.py +0 -0
  145. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/payment/__init__.py +0 -0
  146. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/payment/authorizenet_adapter.py +0 -0
  147. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/payment/authorizenet_mirror.py +0 -0
  148. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/payment/base_adapter.py +0 -0
  149. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/payment/braintree_adapter.py +0 -0
  150. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/payment/braintree_mirror.py +0 -0
  151. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/payment/charge_rules.py +0 -0
  152. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/payment/stripe_adapter.py +0 -0
  153. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity/payment/stripe_mirror.py +0 -0
  154. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity_python.egg-info/dependency_links.txt +0 -0
  155. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity_python.egg-info/entry_points.txt +0 -0
  156. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity_python.egg-info/requires.txt +0 -0
  157. {velocity_python-0.1.62 → velocity_python-0.1.66}/src/velocity_python.egg-info/top_level.txt +0 -0
  158. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_amplify_build.py +0 -0
  159. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_asset_indexing.py +0 -0
  160. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_asset_references.py +0 -0
  161. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_assets_service.py +0 -0
  162. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_async_support.py +0 -0
  163. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_batch_operations.py +0 -0
  164. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_concurrency_safety.py +0 -0
  165. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_connection_pool.py +0 -0
  166. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_connection_resilience.py +0 -0
  167. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_context_job_descriptions.py +0 -0
  168. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_db_credentials_ssm_cascade.py +0 -0
  169. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_decorators.py +0 -0
  170. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_dirty_pipeline_fast_path.py +0 -0
  171. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_email_processing.py +0 -0
  172. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_http_handler_rollback.py +0 -0
  173. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_iconv_money_to_cents.py +0 -0
  174. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_identifier_injection_guard.py +0 -0
  175. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_jsonb_dict_adapter.py +0 -0
  176. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_lambda_handler.py +0 -0
  177. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_lambda_handler_auth.py +0 -0
  178. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_mixins_import.py +0 -0
  179. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_n_plus_one.py +0 -0
  180. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_observability.py +0 -0
  181. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_payment_authorizenet_adapter.py +0 -0
  182. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_payment_braintree_adapter.py +0 -0
  183. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_payment_braintree_mirror.py +0 -0
  184. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_payment_profile_sorting.py +0 -0
  185. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_payment_stripe_adapter.py +0 -0
  186. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_pdf.py +0 -0
  187. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_prepared_statements.py +0 -0
  188. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_psycopg3_upgrade.py +0 -0
  189. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_query_cache.py +0 -0
  190. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_restricted_direct_tables.py +0 -0
  191. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_retry_side_effect_guard.py +0 -0
  192. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_return_default_safety.py +0 -0
  193. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_row_batch_update.py +0 -0
  194. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_row_cache_staleness.py +0 -0
  195. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_row_dirty_tracking.py +0 -0
  196. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_schema_migrations.py +0 -0
  197. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_security_hardening.py +0 -0
  198. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_server_cursor.py +0 -0
  199. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_single_autocommit_safety.py +0 -0
  200. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_spreadsheet_functions.py +0 -0
  201. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_sqs_per_record_transactions.py +0 -0
  202. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_ssm_config.py +0 -0
  203. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_sys_modified_count_postgres_demo.py +0 -0
  204. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_table_alter.py +0 -0
  205. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_where_clause_validation.py +0 -0
  206. {velocity_python-0.1.62 → velocity_python-0.1.66}/tests/test_write_hook_create_flow.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: velocity-python
3
- Version: 0.1.62
3
+ Version: 0.1.66
4
4
  Summary: A rapid application development library for interfacing with data storage
5
5
  Author-email: Velocity Team <info@codeclubs.org>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "velocity-python"
7
- version = "0.1.62"
7
+ version = "0.1.66"
8
8
  authors = [
9
9
  { name="Velocity Team", email="info@codeclubs.org" },
10
10
  ]
@@ -1,4 +1,4 @@
1
- __version__ = version = "0.1.62"
1
+ __version__ = version = "0.1.66"
2
2
 
3
3
  import importlib as _importlib
4
4
 
@@ -167,6 +167,43 @@ class Context:
167
167
  self.perf.set_enabled(enabled)
168
168
  return enabled
169
169
 
170
+ # Header (case-insensitive) carrying a signed masquerade grant minted by an
171
+ # authorized administrative service. See velocity.aws.handlers.masquerade.
172
+ MASQUERADE_HEADER = "x-cc-masquerade"
173
+
174
+ def get_masquerade_grant(self):
175
+ """Return the verified masquerade grant payload for this request, or None.
176
+
177
+ The grant is read from the ``x-cc-masquerade`` header and verified
178
+ against the ``MasqueradeSigningKey`` config value. Returns ``None`` when
179
+ the feature is off (no key), no header is present, or the grant is
180
+ invalid/expired — callers then fall back to normal identity. A present
181
+ but invalid grant is logged and treated as absent (fail closed).
182
+ """
183
+ headers = self.__aws_event.get("headers") or {}
184
+ token = None
185
+ for name, value in headers.items():
186
+ if isinstance(name, str) and name.lower() == self.MASQUERADE_HEADER:
187
+ token = value
188
+ break
189
+ if not token:
190
+ return None
191
+
192
+ secret = config_getenv("MasqueradeSigningKey", "") or ""
193
+ if not secret:
194
+ logger.warning(
195
+ "Masquerade grant header present but MasqueradeSigningKey is not configured"
196
+ )
197
+ return None
198
+
199
+ from velocity.aws.handlers.masquerade import MasqueradeError, verify_grant
200
+
201
+ try:
202
+ return verify_grant(secret, token)
203
+ except MasqueradeError as exc:
204
+ logger.warning("Rejected masquerade grant: %s", exc)
205
+ return None
206
+
170
207
  def _build_session(self, aws_event):
171
208
  request_context = aws_event.get("requestContext") or {}
172
209
  identity = request_context.get("identity") or {}
@@ -69,7 +69,40 @@ class LambdaHandler(BaseHandler):
69
69
  auth_mode = "none"
70
70
  require_db_user = False
71
71
 
72
- if auth_mode == "none":
72
+ # Masquerade: a signed grant lets an authorized admin act as another
73
+ # user. When a valid grant scoped to this app's user_table is present,
74
+ # the request identity resolves to the target (effective_user) exactly
75
+ # as if they had signed in, while the real admin is retained for audit.
76
+ # The grant signature is verified in context.get_masquerade_grant().
77
+ masquerade = None
78
+ if (
79
+ auth_mode != "none"
80
+ and getattr(self, "allow_masquerade", True)
81
+ and getattr(self, "user_table", None)
82
+ ):
83
+ grant = context.get_masquerade_grant()
84
+ if grant and grant.get("pool") == self.user_table:
85
+ masquerade = grant
86
+ session["real_user"] = grant["real_user"]
87
+ session["email_address"] = grant["effective_user"]
88
+ session["masquerade"] = {
89
+ "real_user": grant["real_user"],
90
+ "effective_user": grant["effective_user"],
91
+ "pool": grant["pool"],
92
+ "jti": grant.get("jti"),
93
+ "exp": grant.get("exp"),
94
+ }
95
+ logger.info(
96
+ "Masquerade active: %s acting as %s on %s",
97
+ grant["real_user"],
98
+ grant["effective_user"],
99
+ grant["pool"],
100
+ )
101
+
102
+ if masquerade or auth_mode == "none":
103
+ # In masquerade mode the caller's own Cognito token is for a
104
+ # different pool (e.g. the admin pool), so skip the pool-scoped
105
+ # Cognito lookup; identity comes from the verified grant instead.
73
106
  self.cognito_user = None
74
107
  else:
75
108
  context.perf.start("get_cognito_user")
@@ -0,0 +1,150 @@
1
+ """Signed masquerade (impersonation) grants.
2
+
3
+ A masquerade grant is a compact, HMAC-SHA256 signed token that authorizes one
4
+ identity (``real_user``) to act as another (``effective_user``) within a named
5
+ scope (``pool``) for a short window. It is the trust vehicle for admin
6
+ "masquerade as user" sessions: an administrative service mints a grant after
7
+ authorizing the operation, and the target application verifies the grant's
8
+ signature before resolving the request's identity to ``effective_user`` while
9
+ retaining ``real_user`` for audit.
10
+
11
+ The format is intentionally small and dependency-free (no JWT library):
12
+
13
+ base64url(payload_json) + "." + base64url(hmac_sha256(secret, body))
14
+
15
+ Payload claims:
16
+
17
+ real_user identity performing the masquerade (e.g. admin email)
18
+ effective_user identity being acted as (e.g. donor/client email)
19
+ pool scope the grant is valid for (e.g. "client_users")
20
+ iat issued-at unix seconds
21
+ exp expiry unix seconds
22
+ jti unique id for one-time-use / revocation tracking
23
+
24
+ This module is business-agnostic: it knows nothing about CaringCent pools,
25
+ Cognito, or specific apps. Callers supply the secret and claim values.
26
+ """
27
+
28
+ import base64
29
+ import hashlib
30
+ import hmac
31
+ import json
32
+ import secrets as _secrets
33
+ import time
34
+
35
+ __all__ = [
36
+ "MasqueradeError",
37
+ "mint_grant",
38
+ "verify_grant",
39
+ "DEFAULT_TTL_SECONDS",
40
+ ]
41
+
42
+ DEFAULT_TTL_SECONDS = 900 # 15 minutes
43
+
44
+
45
+ class MasqueradeError(Exception):
46
+ """Raised when a masquerade grant is malformed, unsigned, or expired."""
47
+
48
+
49
+ def _b64u_encode(raw: bytes) -> str:
50
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
51
+
52
+
53
+ def _b64u_decode(value: str) -> bytes:
54
+ padding = "=" * (-len(value) % 4)
55
+ return base64.urlsafe_b64decode(value + padding)
56
+
57
+
58
+ def _secret_bytes(secret) -> bytes:
59
+ if secret is None:
60
+ raise MasqueradeError("Masquerade signing secret is not configured")
61
+ if isinstance(secret, bytes):
62
+ secret_bytes = secret
63
+ else:
64
+ secret_bytes = str(secret).encode("utf-8")
65
+ if not secret_bytes:
66
+ raise MasqueradeError("Masquerade signing secret is empty")
67
+ return secret_bytes
68
+
69
+
70
+ def _sign(secret_bytes: bytes, body: str) -> str:
71
+ digest = hmac.new(secret_bytes, body.encode("ascii"), hashlib.sha256).digest()
72
+ return _b64u_encode(digest)
73
+
74
+
75
+ def mint_grant(
76
+ secret,
77
+ *,
78
+ real_user: str,
79
+ effective_user: str,
80
+ pool: str,
81
+ ttl_seconds: int = DEFAULT_TTL_SECONDS,
82
+ jti: str = None,
83
+ now: int = None,
84
+ ) -> str:
85
+ """Create a signed masquerade grant string.
86
+
87
+ ``real_user``, ``effective_user`` and ``pool`` are required and must be
88
+ non-empty. ``ttl_seconds`` bounds validity; ``jti`` defaults to a random id.
89
+ """
90
+ real_user = (real_user or "").strip()
91
+ effective_user = (effective_user or "").strip()
92
+ pool = (pool or "").strip()
93
+ if not real_user or not effective_user or not pool:
94
+ raise MasqueradeError("real_user, effective_user and pool are required")
95
+ if ttl_seconds <= 0:
96
+ raise MasqueradeError("ttl_seconds must be positive")
97
+
98
+ issued = int(now if now is not None else time.time())
99
+ payload = {
100
+ "real_user": real_user,
101
+ "effective_user": effective_user,
102
+ "pool": pool,
103
+ "iat": issued,
104
+ "exp": issued + int(ttl_seconds),
105
+ "jti": jti or _secrets.token_urlsafe(16),
106
+ }
107
+ body = _b64u_encode(
108
+ json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
109
+ )
110
+ signature = _sign(_secret_bytes(secret), body)
111
+ return f"{body}.{signature}"
112
+
113
+
114
+ def verify_grant(secret, token, *, now: int = None) -> dict:
115
+ """Verify a grant's signature and expiry, returning its payload.
116
+
117
+ Raises :class:`MasqueradeError` if the token is malformed, the signature
118
+ does not match, the payload is structurally invalid, or it has expired.
119
+ """
120
+ if not token or not isinstance(token, str) or "." not in token:
121
+ raise MasqueradeError("Malformed masquerade grant")
122
+
123
+ body, _, signature = token.partition(".")
124
+ expected = _sign(_secret_bytes(secret), body)
125
+ # Constant-time comparison to avoid signature timing oracles.
126
+ if not hmac.compare_digest(signature, expected):
127
+ raise MasqueradeError("Masquerade grant signature mismatch")
128
+
129
+ try:
130
+ payload = json.loads(_b64u_decode(body).decode("utf-8"))
131
+ except (ValueError, UnicodeDecodeError) as exc:
132
+ raise MasqueradeError("Masquerade grant payload is not valid JSON") from exc
133
+
134
+ if not isinstance(payload, dict):
135
+ raise MasqueradeError("Masquerade grant payload is not an object")
136
+
137
+ for field in ("real_user", "effective_user", "pool", "exp"):
138
+ if not payload.get(field):
139
+ raise MasqueradeError(f"Masquerade grant missing '{field}'")
140
+
141
+ current = int(now if now is not None else time.time())
142
+ try:
143
+ expires = int(payload["exp"])
144
+ except (TypeError, ValueError) as exc:
145
+ raise MasqueradeError("Masquerade grant 'exp' is not an integer") from exc
146
+
147
+ if current >= expires:
148
+ raise MasqueradeError("Masquerade grant has expired")
149
+
150
+ return payload
@@ -461,12 +461,17 @@ Request Details:
461
461
  data = context.get_pass_through_vars(tx)
462
462
  current_user = getattr(self, "current_user", None) or {}
463
463
  context.response().load_object(current_user)
464
+ controls = {
465
+ **data.get("controls", {}),
466
+ "current_user": current_user,
467
+ }
468
+ # When the session is a masquerade, surface it so the UI can show the
469
+ # admin's real identity alongside the user they are acting as.
470
+ masquerade = (context.session() or {}).get("masquerade")
471
+ controls["masquerade"] = masquerade or None
464
472
  context.response().update_store(
465
473
  {
466
- "controls": {
467
- **data.get("controls", {}),
468
- "current_user": current_user,
469
- },
474
+ "controls": controls,
470
475
  "repo": {
471
476
  "current_user": current_user,
472
477
  },
@@ -0,0 +1,175 @@
1
+ """
2
+ Live list/dict proxies for JSON column values.
3
+
4
+ A ``list`` or ``dict`` stored in a row is just a JSON column (JSONB on
5
+ PostgreSQL). ``Row.__getitem__`` wraps such values in :class:`BoundList` /
6
+ :class:`BoundDict` so in-place mutation writes the whole value back through
7
+ the owning row::
8
+
9
+ row["tags"].append("vip") # UPDATE ... SET tags = '["a","vip"]'
10
+ row["meta"]["verified"] = True # whole "meta" value written back
11
+
12
+ There are no relation declarations and no diffing: every mutation persists
13
+ the complete top-level value. Nested containers share the root's flush, so
14
+ a deep mutation also writes the root value back.
15
+ """
16
+
17
+
18
+ class _Controller:
19
+ """Shared link between a root proxy and all of its nested proxies."""
20
+
21
+ __slots__ = ("flush", "root")
22
+
23
+ def __init__(self, flush):
24
+ self.flush = flush
25
+ self.root = None
26
+
27
+ def notify(self):
28
+ self.flush(unwrap(self.root))
29
+
30
+
31
+ def bind(value, flush):
32
+ """
33
+ Wrap ``value`` (a dict or list) in a bound proxy tree.
34
+
35
+ ``flush(plain_value)`` is called with the plain (unwrapped) top-level
36
+ value after every mutation anywhere in the tree. Non-container values
37
+ are returned unchanged.
38
+ """
39
+ if not isinstance(value, (dict, list)):
40
+ return value
41
+ ctrl = _Controller(flush)
42
+ root = _wrap(value, ctrl)
43
+ ctrl.root = root
44
+ return root
45
+
46
+
47
+ def unwrap(value):
48
+ """Return a plain dict/list copy of a (possibly bound) value tree."""
49
+ if isinstance(value, dict):
50
+ return {k: unwrap(v) for k, v in value.items()}
51
+ if isinstance(value, list):
52
+ return [unwrap(v) for v in value]
53
+ return value
54
+
55
+
56
+ def _wrap(value, ctrl):
57
+ if isinstance(value, dict):
58
+ return BoundDict(value, ctrl)
59
+ if isinstance(value, list):
60
+ return BoundList(value, ctrl)
61
+ return value
62
+
63
+
64
+ class BoundList(list):
65
+ """A list whose mutations flush the root value back to its column."""
66
+
67
+ def __init__(self, iterable, ctrl):
68
+ super().__init__(_wrap(v, ctrl) for v in iterable)
69
+ self._ctrl = ctrl
70
+
71
+ # -- mutators ------------------------------------------------------
72
+ def __setitem__(self, index, value):
73
+ if isinstance(index, slice):
74
+ value = [_wrap(v, self._ctrl) for v in value]
75
+ else:
76
+ value = _wrap(value, self._ctrl)
77
+ super().__setitem__(index, value)
78
+ self._ctrl.notify()
79
+
80
+ def __delitem__(self, index):
81
+ super().__delitem__(index)
82
+ self._ctrl.notify()
83
+
84
+ def __iadd__(self, other):
85
+ self.extend(other)
86
+ return self
87
+
88
+ def __imul__(self, n):
89
+ result = super().__imul__(n)
90
+ self._ctrl.notify()
91
+ return result
92
+
93
+ def append(self, value):
94
+ super().append(_wrap(value, self._ctrl))
95
+ self._ctrl.notify()
96
+
97
+ def extend(self, iterable):
98
+ super().extend(_wrap(v, self._ctrl) for v in iterable)
99
+ self._ctrl.notify()
100
+
101
+ def insert(self, index, value):
102
+ super().insert(index, _wrap(value, self._ctrl))
103
+ self._ctrl.notify()
104
+
105
+ def remove(self, value):
106
+ super().remove(value)
107
+ self._ctrl.notify()
108
+
109
+ def pop(self, index=-1):
110
+ result = super().pop(index)
111
+ self._ctrl.notify()
112
+ return result
113
+
114
+ def clear(self):
115
+ super().clear()
116
+ self._ctrl.notify()
117
+
118
+ def sort(self, **kwds):
119
+ super().sort(**kwds)
120
+ self._ctrl.notify()
121
+
122
+ def reverse(self):
123
+ super().reverse()
124
+ self._ctrl.notify()
125
+
126
+
127
+ class BoundDict(dict):
128
+ """A dict whose mutations flush the root value back to its column."""
129
+
130
+ def __init__(self, mapping, ctrl):
131
+ super().__init__({k: _wrap(v, ctrl) for k, v in mapping.items()})
132
+ self._ctrl = ctrl
133
+
134
+ # -- mutators ------------------------------------------------------
135
+ def __setitem__(self, key, value):
136
+ super().__setitem__(key, _wrap(value, self._ctrl))
137
+ self._ctrl.notify()
138
+
139
+ def __delitem__(self, key):
140
+ super().__delitem__(key)
141
+ self._ctrl.notify()
142
+
143
+ def update(self, dict_=None, **kwds):
144
+ data = {}
145
+ if dict_:
146
+ data.update(dict_)
147
+ data.update(kwds)
148
+ for k, v in data.items():
149
+ super().__setitem__(k, _wrap(v, self._ctrl))
150
+ if data:
151
+ self._ctrl.notify()
152
+
153
+ def pop(self, key, *args):
154
+ existed = key in self
155
+ result = super().pop(key, *args)
156
+ if existed:
157
+ self._ctrl.notify()
158
+ return result
159
+
160
+ def popitem(self):
161
+ result = super().popitem()
162
+ self._ctrl.notify()
163
+ return result
164
+
165
+ def clear(self):
166
+ super().clear()
167
+ self._ctrl.notify()
168
+
169
+ def setdefault(self, key, default=None):
170
+ if key in self:
171
+ return self[key]
172
+ wrapped = _wrap(default, self._ctrl)
173
+ super().__setitem__(key, wrapped)
174
+ self._ctrl.notify()
175
+ return wrapped
@@ -2,6 +2,7 @@ import pprint
2
2
  import time as _time
3
3
  from collections.abc import MutableMapping
4
4
  from velocity.db.exceptions import DbColumnMissingError
5
+ from velocity.db.core import jsonproxy
5
6
 
6
7
 
7
8
  # Attributes that live on the Row instance itself and must never be
@@ -133,9 +134,24 @@ class Row(MutableMapping):
133
134
  return self.pk[key]
134
135
  cache = self._ensure_cache()
135
136
  if key in cache:
136
- return cache[key]
137
+ val = cache[key]
138
+ # JSON columns come back as plain dict/list — wrap them in live
139
+ # proxies so in-place mutation writes the whole value back.
140
+ if isinstance(val, (dict, list)) and not isinstance(
141
+ val, (jsonproxy.BoundDict, jsonproxy.BoundList)
142
+ ):
143
+ val = jsonproxy.bind(
144
+ val, lambda plain, _k=key: self.__setitem__(_k, plain)
145
+ )
146
+ cache[key] = val
147
+ return val
137
148
  # Fall back to a direct DB fetch for columns not in the initial SELECT
138
- return self.table.get_value(key, self.pk)
149
+ val = self.table.get_value(key, self.pk)
150
+ if isinstance(val, (dict, list)):
151
+ val = jsonproxy.bind(
152
+ val, lambda plain, _k=key: self.__setitem__(_k, plain)
153
+ )
154
+ return val
139
155
 
140
156
  def __setitem__(self, key, val):
141
157
  if key in self.pk:
@@ -327,7 +343,7 @@ class Row(MutableMapping):
327
343
  """
328
344
  Returns the row as a dictionary (from cache or via a SELECT on self.pk).
329
345
  """
330
- return dict(self._ensure_cache())
346
+ return {k: jsonproxy.unwrap(v) for k, v in self._ensure_cache().items()}
331
347
 
332
348
  def extract(self, *args):
333
349
  """
@@ -1984,12 +1984,21 @@ class Table:
1984
1984
  _ddl_logger.warning("DDL ALTER COLUMN TYPE on %s column=%s", self.name, column)
1985
1985
  self.tx.execute(sql, vals, cursor=self.cursor())
1986
1986
 
1987
+ def _adapt_data(self, data):
1988
+ """
1989
+ Apply the dialect's data-value adaptation (e.g. dict/list -> JSONB on
1990
+ PostgreSQL, JSON text elsewhere) to a column->value mapping. Used for
1991
+ INSERT/UPDATE data only — WHERE values keep native driver adaptation.
1992
+ """
1993
+ adapt = self.sql.adapt_data_value
1994
+ return {k: adapt(v) for k, v in data.items()}
1995
+
1987
1996
  @create_missing
1988
1997
  def update(self, data, where=None, pk=None, **kwds):
1989
1998
  """
1990
1999
  Performs an UPDATE of rows matching `where` or `pk` with `data`.
1991
2000
  """
1992
- sql, vals = self.sql.update(self.tx, self.name, data, where, pk)
2001
+ sql, vals = self.sql.update(self.tx, self.name, self._adapt_data(data), where, pk)
1993
2002
  if kwds.get("sql_only", False):
1994
2003
  return sql, vals
1995
2004
  self.tx.invalidate_cache(self.name)
@@ -2002,7 +2011,7 @@ class Table:
2002
2011
  """
2003
2012
  Performs an INSERT of the given data into this table. Resets sys_id on duplicate keys if needed.
2004
2013
  """
2005
- sql, vals = self.sql.insert(self.name, data)
2014
+ sql, vals = self.sql.insert(self.name, self._adapt_data(data))
2006
2015
  if kwds.get("sql_only", False):
2007
2016
  return sql, vals
2008
2017
  self.tx.invalidate_cache(self.name)
@@ -2018,7 +2027,7 @@ class Table:
2018
2027
  sql, vals = self.sql.merge(
2019
2028
  self.tx,
2020
2029
  self.name,
2021
- data,
2030
+ self._adapt_data(data),
2022
2031
  pk,
2023
2032
  on_conflict_do_nothing=False,
2024
2033
  on_conflict_update=True,
@@ -2047,7 +2056,9 @@ class Table:
2047
2056
  """
2048
2057
  if not rows:
2049
2058
  return 0
2050
- sql, args_list, template = self.sql.insert_many(self.name, rows)
2059
+ sql, args_list, template = self.sql.insert_many(
2060
+ self.name, [self._adapt_data(row) for row in rows]
2061
+ )
2051
2062
  page_size = kwds.get("page_size", 1000)
2052
2063
  self.tx.invalidate_cache(self.name)
2053
2064
  return self.tx._execute_values(sql, args_list, template=template, page_size=page_size)
@@ -2068,7 +2079,9 @@ class Table:
2068
2079
  """
2069
2080
  if not rows:
2070
2081
  return 0
2071
- sql, args_list, template = self.sql.merge_many(self.tx, self.name, rows, pk=pk)
2082
+ sql, args_list, template = self.sql.merge_many(
2083
+ self.tx, self.name, [self._adapt_data(row) for row in rows], pk=pk
2084
+ )
2072
2085
  page_size = kwds.get("page_size", 1000)
2073
2086
  self.tx.invalidate_cache(self.name)
2074
2087
  return self.tx._execute_values(sql, args_list, template=template, page_size=page_size)
@@ -2172,7 +2185,9 @@ class Table:
2172
2185
  "Current SQL dialect does not support insert-if-not-exists operations."
2173
2186
  )
2174
2187
 
2175
- sql, vals = ins_builder(self.tx, self.name, insert_payload, exists_where)
2188
+ sql, vals = ins_builder(
2189
+ self.tx, self.name, self._adapt_data(insert_payload), exists_where
2190
+ )
2176
2191
  if sql_only:
2177
2192
  return {"update": update_stmt, "insert": (sql, vals)}
2178
2193
  result = self.tx.execute(sql, vals, cursor=self.cursor())
@@ -2200,7 +2215,9 @@ class Table:
2200
2215
  must be present in `data`.
2201
2216
  :return: rowcount (0 or 1) or (sql, params) when sql_only=True
2202
2217
  """
2203
- sql, vals = self.sql.insert_if_not_exists(self.tx, self.name, data, where)
2218
+ sql, vals = self.sql.insert_if_not_exists(
2219
+ self.tx, self.name, self._adapt_data(data), where
2220
+ )
2204
2221
  if kwds.get("sql_only", False):
2205
2222
  return sql, vals
2206
2223
  result = self.tx.execute(sql, vals, cursor=self.cursor())
@@ -2,6 +2,7 @@
2
2
  Abstract base class for SQL dialect implementations.
3
3
  """
4
4
 
5
+ import json
5
6
  from abc import ABC, abstractmethod
6
7
  from typing import Any, Dict, List, Optional, Tuple, Union
7
8
 
@@ -38,6 +39,21 @@ class BaseSQLDialect(ABC):
38
39
  DatabaseObjectExistsErrorCodes: List[str] = []
39
40
  DataIntegrityErrorCodes: List[str] = []
40
41
 
42
+ @classmethod
43
+ def adapt_data_value(cls, val: Any) -> Any:
44
+ """
45
+ Adapt a Python value being written to a column (INSERT/UPDATE data
46
+ values only — never WHERE parameters, which keep their native driver
47
+ adaptation, e.g. list -> ANY(...) on PostgreSQL).
48
+
49
+ Default: serialize dict/list to a JSON string for backends without a
50
+ native JSON parameter adapter (SQLite, SQL Server, MySQL). PostgreSQL
51
+ overrides this to bind JSONB natively.
52
+ """
53
+ if isinstance(val, (dict, list)):
54
+ return json.dumps(val)
55
+ return val
56
+
41
57
  @classmethod
42
58
  def quote_identifier(cls, name: str) -> str:
43
59
  """Always-quote a single SQL identifier to prevent injection.
@@ -23,6 +23,7 @@ class TYPES(BaseTypes):
23
23
  LONGTEXT = "LONGTEXT"
24
24
  MEDIUMTEXT = "MEDIUMTEXT"
25
25
  VARCHAR = "VARCHAR"
26
+ JSON = "JSON"
26
27
 
27
28
  @classmethod
28
29
  def get_type(cls, v):
@@ -51,6 +52,8 @@ class TYPES(BaseTypes):
51
52
  return cls.TIME
52
53
  if isinstance(v, bytes) or v is bytes:
53
54
  return cls.BINARY
55
+ if isinstance(v, (dict, list)) or v is dict or v is list:
56
+ return cls.JSON
54
57
  return cls.TEXT
55
58
 
56
59
  @classmethod
@@ -80,6 +83,8 @@ class TYPES(BaseTypes):
80
83
  return cls.TIME
81
84
  if isinstance(v, bytes) or v is bytes:
82
85
  return cls.BINARY
86
+ if isinstance(v, (dict, list)) or v is dict or v is list:
87
+ return cls.JSON
83
88
  return cls.TEXT
84
89
 
85
90
  @classmethod
@@ -108,4 +113,6 @@ class TYPES(BaseTypes):
108
113
  return datetime.datetime
109
114
  if v == cls.BINARY or "BLOB" in v:
110
115
  return bytes
116
+ if v == cls.JSON:
117
+ return dict
111
118
  raise Exception(f"Unmapped MySQL type {v}")
@@ -71,6 +71,20 @@ class SQL(BaseSQLDialect):
71
71
  DatabaseObjectExistsErrorCodes = ["42710", "42P07", "42P04"]
72
72
  DataIntegrityErrorCodes = ["23503", "23502", "23514", "23P01", "22003"]
73
73
 
74
+ @classmethod
75
+ def adapt_data_value(cls, val):
76
+ """
77
+ Bind dict/list data values as JSONB. Lists must be wrapped here
78
+ because psycopg's native list adaptation targets PostgreSQL arrays
79
+ (kept for WHERE params, e.g. ``= ANY(%s)``); bare dicts also work via
80
+ the engine-registered dumper, but wrapping both keeps it uniform.
81
+ """
82
+ if isinstance(val, (dict, list)):
83
+ from psycopg.types.json import Jsonb
84
+
85
+ return Jsonb(val)
86
+ return val
87
+
74
88
  @classmethod
75
89
  def get_error(cls, e):
76
90
  # psycopg v3 uses 'sqlstate'; psycopg2 used 'pgcode'.