velocity-python 0.0.202__tar.gz → 0.0.204__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 (149) hide show
  1. {velocity_python-0.0.202 → velocity_python-0.0.204}/PKG-INFO +1 -1
  2. {velocity_python-0.0.202 → velocity_python-0.0.204}/pyproject.toml +1 -1
  3. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/__init__.py +1 -1
  4. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/core/engine.py +61 -0
  5. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/core/transaction.py +15 -0
  6. velocity_python-0.0.204/src/velocity/db/core/view.py +158 -0
  7. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/servers/postgres/sql.py +2 -2
  8. velocity_python-0.0.204/src/velocity/db/tests/test_view_helper.py +97 -0
  9. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity_python.egg-info/PKG-INFO +1 -1
  10. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity_python.egg-info/SOURCES.txt +2 -0
  11. {velocity_python-0.0.202 → velocity_python-0.0.204}/tests/test_table_alter.py +5 -0
  12. {velocity_python-0.0.202 → velocity_python-0.0.204}/LICENSE +0 -0
  13. {velocity_python-0.0.202 → velocity_python-0.0.204}/README.md +0 -0
  14. {velocity_python-0.0.202 → velocity_python-0.0.204}/setup.cfg +0 -0
  15. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/app/__init__.py +0 -0
  16. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/app/invoices.py +0 -0
  17. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/app/orders.py +0 -0
  18. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/app/payments.py +0 -0
  19. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/app/purchase_orders.py +0 -0
  20. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/app/tests/__init__.py +0 -0
  21. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/app/tests/test_email_processing.py +0 -0
  22. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/app/tests/test_payment_profile_sorting.py +0 -0
  23. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/app/tests/test_spreadsheet_functions.py +0 -0
  24. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/aws/__init__.py +0 -0
  25. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/aws/amplify.py +0 -0
  26. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/aws/handlers/__init__.py +0 -0
  27. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/aws/handlers/base_handler.py +0 -0
  28. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/aws/handlers/context.py +0 -0
  29. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/aws/handlers/context_factory.py +0 -0
  30. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/aws/handlers/exceptions.py +0 -0
  31. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/aws/handlers/lambda_handler.py +0 -0
  32. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/aws/handlers/mixins/__init__.py +0 -0
  33. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/aws/handlers/mixins/data_service.py +0 -0
  34. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/aws/handlers/mixins/web_handler.py +0 -0
  35. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/aws/handlers/perf.py +0 -0
  36. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/aws/handlers/response.py +0 -0
  37. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/aws/handlers/sqs_handler.py +0 -0
  38. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/aws/tests/__init__.py +0 -0
  39. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/aws/tests/test_lambda_handler_json_serialization.py +0 -0
  40. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/aws/tests/test_response.py +0 -0
  41. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/__init__.py +0 -0
  42. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/core/__init__.py +0 -0
  43. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/core/column.py +0 -0
  44. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/core/database.py +0 -0
  45. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/core/decorators.py +0 -0
  46. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/core/result.py +0 -0
  47. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/core/row.py +0 -0
  48. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/core/sequence.py +0 -0
  49. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/core/table.py +0 -0
  50. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/exceptions.py +0 -0
  51. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/servers/__init__.py +0 -0
  52. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/servers/base/__init__.py +0 -0
  53. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/servers/base/initializer.py +0 -0
  54. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/servers/base/operators.py +0 -0
  55. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/servers/base/sql.py +0 -0
  56. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/servers/base/types.py +0 -0
  57. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/servers/mysql/__init__.py +0 -0
  58. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/servers/mysql/operators.py +0 -0
  59. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/servers/mysql/reserved.py +0 -0
  60. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/servers/mysql/sql.py +0 -0
  61. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/servers/mysql/types.py +0 -0
  62. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/servers/postgres/__init__.py +0 -0
  63. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/servers/postgres/operators.py +0 -0
  64. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/servers/postgres/reserved.py +0 -0
  65. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/servers/postgres/types.py +0 -0
  66. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/servers/sqlite/__init__.py +0 -0
  67. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/servers/sqlite/operators.py +0 -0
  68. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/servers/sqlite/reserved.py +0 -0
  69. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/servers/sqlite/sql.py +0 -0
  70. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/servers/sqlite/types.py +0 -0
  71. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/servers/sqlserver/__init__.py +0 -0
  72. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/servers/sqlserver/operators.py +0 -0
  73. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/servers/sqlserver/reserved.py +0 -0
  74. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/servers/sqlserver/sql.py +0 -0
  75. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/servers/sqlserver/types.py +0 -0
  76. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/servers/tablehelper.py +0 -0
  77. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/__init__.py +0 -0
  78. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/common_db_test.py +0 -0
  79. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/postgres/__init__.py +0 -0
  80. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/postgres/common.py +0 -0
  81. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/postgres/test_column.py +0 -0
  82. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/postgres/test_connections.py +0 -0
  83. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/postgres/test_database.py +0 -0
  84. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/postgres/test_engine.py +0 -0
  85. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/postgres/test_general_usage.py +0 -0
  86. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/postgres/test_imports.py +0 -0
  87. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/postgres/test_result.py +0 -0
  88. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/postgres/test_row.py +0 -0
  89. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/postgres/test_row_comprehensive.py +0 -0
  90. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/postgres/test_schema_locking.py +0 -0
  91. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/postgres/test_schema_locking_unit.py +0 -0
  92. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/postgres/test_sequence.py +0 -0
  93. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/postgres/test_sql_comprehensive.py +0 -0
  94. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/postgres/test_table.py +0 -0
  95. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/postgres/test_table_comprehensive.py +0 -0
  96. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/postgres/test_transaction.py +0 -0
  97. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/sql/__init__.py +0 -0
  98. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/sql/common.py +0 -0
  99. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/sql/test_postgres_select_advanced.py +0 -0
  100. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/sql/test_postgres_select_variances.py +0 -0
  101. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/test_cursor_rowcount_fix.py +0 -0
  102. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/test_db_utils.py +0 -0
  103. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/test_postgres.py +0 -0
  104. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/test_postgres_unchanged.py +0 -0
  105. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/test_process_error_robustness.py +0 -0
  106. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/test_result_caching.py +0 -0
  107. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/test_result_sql_aware.py +0 -0
  108. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/test_row_get_missing_column.py +0 -0
  109. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/test_schema_locking_initializers.py +0 -0
  110. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/test_schema_locking_simple.py +0 -0
  111. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/test_sql_builder.py +0 -0
  112. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/tests/test_tablehelper.py +0 -0
  113. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/db/utils.py +0 -0
  114. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/logging.py +0 -0
  115. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/misc/__init__.py +0 -0
  116. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/misc/conv/__init__.py +0 -0
  117. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/misc/conv/iconv.py +0 -0
  118. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/misc/conv/oconv.py +0 -0
  119. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/misc/db.py +0 -0
  120. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/misc/export.py +0 -0
  121. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/misc/format.py +0 -0
  122. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/misc/mail.py +0 -0
  123. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/misc/merge.py +0 -0
  124. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/misc/tests/__init__.py +0 -0
  125. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/misc/tests/test_db.py +0 -0
  126. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/misc/tests/test_fix.py +0 -0
  127. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/misc/tests/test_format.py +0 -0
  128. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/misc/tests/test_iconv.py +0 -0
  129. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/misc/tests/test_merge.py +0 -0
  130. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/misc/tests/test_oconv.py +0 -0
  131. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/misc/tests/test_original_error.py +0 -0
  132. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/misc/tests/test_timer.py +0 -0
  133. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/misc/timer.py +0 -0
  134. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/misc/tools.py +0 -0
  135. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/payment/__init__.py +0 -0
  136. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/payment/base_adapter.py +0 -0
  137. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/payment/braintree_adapter.py +0 -0
  138. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/payment/router.py +0 -0
  139. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity/payment/stripe_adapter.py +0 -0
  140. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity_python.egg-info/dependency_links.txt +0 -0
  141. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity_python.egg-info/requires.txt +0 -0
  142. {velocity_python-0.0.202 → velocity_python-0.0.204}/src/velocity_python.egg-info/top_level.txt +0 -0
  143. {velocity_python-0.0.202 → velocity_python-0.0.204}/tests/test_decorators.py +0 -0
  144. {velocity_python-0.0.202 → velocity_python-0.0.204}/tests/test_iconv_money_to_cents.py +0 -0
  145. {velocity_python-0.0.202 → velocity_python-0.0.204}/tests/test_lambda_handler.py +0 -0
  146. {velocity_python-0.0.202 → velocity_python-0.0.204}/tests/test_lambda_handler_auth.py +0 -0
  147. {velocity_python-0.0.202 → velocity_python-0.0.204}/tests/test_mixins_import.py +0 -0
  148. {velocity_python-0.0.202 → velocity_python-0.0.204}/tests/test_sys_modified_count_postgres_demo.py +0 -0
  149. {velocity_python-0.0.202 → velocity_python-0.0.204}/tests/test_where_clause_validation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: velocity-python
3
- Version: 0.0.202
3
+ Version: 0.0.204
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.0.202"
7
+ version = "0.0.204"
8
8
  authors = [
9
9
  { name="Velocity Team", email="info@codeclubs.org" },
10
10
  ]
@@ -1,4 +1,4 @@
1
- __version__ = version = "0.0.202"
1
+ __version__ = version = "0.0.204"
2
2
 
3
3
  from . import aws
4
4
  from . import db
@@ -1,5 +1,6 @@
1
1
  import inspect
2
2
  import re
3
+ import time
3
4
  from contextlib import contextmanager
4
5
  from functools import wraps
5
6
  from velocity.db import exceptions
@@ -175,6 +176,7 @@ class Engine:
175
176
  else:
176
177
  retry_count = 0
177
178
  lock_timeout_count = 0
179
+ connection_retry_count = 0
178
180
  while True:
179
181
  try:
180
182
  return function(*args, **kwds)
@@ -182,13 +184,35 @@ class Engine:
182
184
  retry_count += 1
183
185
  if retry_count > self.MAX_RETRIES:
184
186
  raise
187
+ # Back off a bit to reduce contention on hot DDL.
188
+ time.sleep(min(2.0, 0.05 * (2**min(retry_count, 6))))
185
189
  _tx.rollback()
186
190
  except exceptions.DbLockTimeoutError:
187
191
  lock_timeout_count += 1
188
192
  if lock_timeout_count > self.MAX_RETRIES:
189
193
  raise
194
+ time.sleep(min(2.0, 0.05 * (2**min(lock_timeout_count, 6))))
190
195
  _tx.rollback()
191
196
  continue
197
+ except exceptions.DbConnectionError as e:
198
+ # Transient disconnects can happen during maintenance / restarts.
199
+ # Retrying the entire top-level function is the safest option.
200
+ msg = str(e).strip().lower()
201
+ if not self._is_transient_connection_error_message(msg):
202
+ raise
203
+
204
+ connection_retry_count += 1
205
+ if connection_retry_count > 6:
206
+ raise
207
+
208
+ # Force a reconnect on the next attempt.
209
+ try:
210
+ _tx.close()
211
+ except Exception:
212
+ _tx.connection = None
213
+
214
+ time.sleep(min(2.0, 0.1 * (2**min(connection_retry_count, 5))))
215
+ continue
192
216
  except Exception:
193
217
  raise
194
218
  finally:
@@ -442,6 +466,13 @@ class Engine:
442
466
  raise exceptions.DbObjectExistsError(enhanced_message) from exception
443
467
  if re.findall(r"server closed the connection unexpectedly", msg, re.M):
444
468
  raise exceptions.DbConnectionError(enhanced_message) from exception
469
+ if re.findall(r"ssl syscall error: eof detected", msg, re.M):
470
+ raise exceptions.DbConnectionError(enhanced_message) from exception
471
+ if re.findall(r"ssl syscall error: connection reset by peer", msg, re.M):
472
+ raise exceptions.DbConnectionError(enhanced_message) from exception
473
+ if re.findall(r"eof detected", msg, re.M):
474
+ # Be conservative: EOFs typically indicate a transient disconnect.
475
+ raise exceptions.DbConnectionError(enhanced_message) from exception
445
476
  if re.findall(r"no connection to the server", msg, re.M):
446
477
  raise exceptions.DbConnectionError(enhanced_message) from exception
447
478
  if re.findall(r"connection timed out", msg, re.M):
@@ -450,6 +481,16 @@ class Engine:
450
481
  raise exceptions.DbConnectionError(enhanced_message) from exception
451
482
  if re.findall(r"cannot connect to server", msg, re.M):
452
483
  raise exceptions.DbConnectionError(enhanced_message) from exception
484
+ if re.findall(r"terminating connection due to administrator command", msg, re.M):
485
+ raise exceptions.DbConnectionError(enhanced_message) from exception
486
+ if re.findall(r"connection reset by peer", msg, re.M):
487
+ raise exceptions.DbConnectionError(enhanced_message) from exception
488
+ if re.findall(r"broken pipe", msg, re.M):
489
+ raise exceptions.DbConnectionError(enhanced_message) from exception
490
+ if re.findall(r"could not receive data from server", msg, re.M):
491
+ raise exceptions.DbConnectionError(enhanced_message) from exception
492
+ if re.findall(r"could not send data to server", msg, re.M):
493
+ raise exceptions.DbConnectionError(enhanced_message) from exception
453
494
  if re.findall(r"connection already closed", msg, re.M):
454
495
  raise exceptions.DbConnectionError(enhanced_message) from exception
455
496
  if re.findall(r"cursor already closed", msg, re.M):
@@ -471,6 +512,26 @@ class Engine:
471
512
  # If we can't classify it, re-raise with enhanced message
472
513
  raise type(exception)(enhanced_message) from exception
473
514
 
515
+ def _is_transient_connection_error_message(self, msg: str) -> bool:
516
+ """Return True if this looks like a retryable, transient connection drop.
517
+
518
+ Keep this intentionally conservative: authentication/config issues should not be retried.
519
+ """
520
+ needles = (
521
+ "ssl syscall error",
522
+ "eof detected",
523
+ "server closed the connection unexpectedly",
524
+ "connection reset by peer",
525
+ "broken pipe",
526
+ "could not receive data from server",
527
+ "could not send data to server",
528
+ "terminating connection due to administrator command",
529
+ "connection already closed",
530
+ "cursor already closed",
531
+ "no connection to the server",
532
+ )
533
+ return any(n in msg for n in needles)
534
+
474
535
  def _format_sql_with_params(self, sql, parameters):
475
536
  """
476
537
  Format SQL query with parameters merged for easy copy-paste debugging.
@@ -2,6 +2,7 @@ import traceback
2
2
 
3
3
  from velocity.db.core.row import Row
4
4
  from velocity.db.core.table import Table
5
+ from velocity.db.core.view import View
5
6
  from velocity.db.core.result import Result
6
7
  from velocity.db.core.column import Column
7
8
  from velocity.db.core.database import Database
@@ -47,6 +48,16 @@ class Transaction:
47
48
  """
48
49
  Retrieves a database cursor, opening a connection if necessary.
49
50
  """
51
+ # Lazily connect, and also recover if the driver reports a closed connection.
52
+ # (psycopg2 uses `connection.closed != 0` to indicate closed.)
53
+ if self.connection is not None:
54
+ try:
55
+ if getattr(self.connection, "closed", 0):
56
+ self.connection = None
57
+ except Exception:
58
+ # If the driver object is in a bad state, force a reconnect.
59
+ self.connection = None
60
+
50
61
  if not self.connection:
51
62
  self.connection = self.engine.connect()
52
63
  if debug:
@@ -157,6 +168,10 @@ class Transaction:
157
168
  """
158
169
  return Table(self, tablename)
159
170
 
171
+ def view(self, viewname, schema=None):
172
+ """Returns a View helper for the given view name."""
173
+ return View(self, viewname, schema=schema)
174
+
160
175
  def sequence(self, name):
161
176
  """
162
177
  Returns a Sequence object for the given sequence name.
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from velocity.db import exceptions
6
+
7
+
8
+ def _quote_ident(identifier: str) -> str:
9
+ return '"' + str(identifier).replace('"', '""') + '"'
10
+
11
+
12
+ def _grantee_sql(grantee: str) -> str:
13
+ normalized = str(grantee).strip()
14
+ if not normalized:
15
+ raise ValueError("grantee cannot be empty")
16
+
17
+ keyword = normalized.upper()
18
+ if keyword in {"PUBLIC", "CURRENT_USER", "SESSION_USER"}:
19
+ return keyword
20
+
21
+ return _quote_ident(normalized)
22
+
23
+
24
+ @dataclass
25
+ class Grant:
26
+ privilege: str
27
+ grantee: str
28
+
29
+
30
+ class View:
31
+ """PostgreSQL view helper.
32
+
33
+ This is intentionally lightweight and uses catalog queries directly rather than
34
+ embedding view DDL in the dialect.
35
+
36
+ Primary goals:
37
+ - Create views in an idempotent way (CREATE OR REPLACE).
38
+ - If the view already exists, "enhance" it by applying missing grants.
39
+
40
+ Notes:
41
+ - For updating the view definition, pass replace_existing=True.
42
+ - Privilege checks use has_table_privilege().
43
+ """
44
+
45
+ def __init__(self, tx, name: str, schema: str | None = None):
46
+ self.tx = tx
47
+ self.name = str(name).lower()
48
+ self.schema = (schema or getattr(tx.engine.sql, "default_schema", None) or "public").lower()
49
+
50
+ @property
51
+ def qualified_name(self) -> str:
52
+ return f"{_quote_ident(self.schema)}.{_quote_ident(self.name)}"
53
+
54
+ def exists(self) -> bool:
55
+ result = self.tx.execute(
56
+ """
57
+ SELECT 1
58
+ FROM information_schema.views
59
+ WHERE table_schema = %s AND table_name = %s
60
+ LIMIT 1
61
+ """,
62
+ [self.schema, self.name],
63
+ )
64
+ try:
65
+ rows = result.as_dict().all()
66
+ except Exception:
67
+ rows = []
68
+ return bool(rows)
69
+
70
+ def create_or_replace(self, select_sql: str) -> None:
71
+ if self.tx.engine.schema_locked:
72
+ raise exceptions.DbSchemaLockedError(
73
+ f"Cannot create/replace view {self.schema}.{self.name}: schema is locked"
74
+ )
75
+
76
+ sql = (select_sql or "").strip().rstrip(";")
77
+ if not sql:
78
+ raise ValueError("select_sql cannot be empty")
79
+
80
+ # If caller already provided CREATE VIEW, execute as-is.
81
+ lowered = sql.lower().lstrip()
82
+ if lowered.startswith("create"):
83
+ self.tx.execute(sql)
84
+ return
85
+
86
+ ddl = f"CREATE OR REPLACE VIEW {self.qualified_name} AS\n{sql}"
87
+ self.tx.execute(ddl)
88
+
89
+ def _has_privilege(self, grantee: str, privilege: str) -> bool:
90
+ # PUBLIC is a PostgreSQL pseudo-role for GRANT, but it's not necessarily a
91
+ # real role that can be passed to has_table_privilege() in all setups.
92
+ # GRANT itself is idempotent, so for PUBLIC we just apply it unconditionally.
93
+ if str(grantee).strip().upper() == "PUBLIC":
94
+ return False
95
+
96
+ # has_table_privilege(role, table, privilege)
97
+ # Note: privilege must be like 'SELECT'.
98
+ result = self.tx.execute(
99
+ "SELECT has_table_privilege(%s, %s, %s) as ok",
100
+ [grantee, f"{self.schema}.{self.name}", privilege],
101
+ )
102
+ try:
103
+ rows = result.as_dict().all()
104
+ except Exception:
105
+ rows = []
106
+ row = rows[0] if rows else None
107
+ return bool(row.get("ok")) if isinstance(row, dict) and row else False
108
+
109
+ def grant(self, privilege: str = "SELECT", grantee: str = "PUBLIC") -> None:
110
+ if self.tx.engine.schema_locked:
111
+ raise exceptions.DbSchemaLockedError(
112
+ f"Cannot grant privileges on {self.schema}.{self.name}: schema is locked"
113
+ )
114
+ privilege = str(privilege).upper().strip()
115
+ if not privilege:
116
+ raise ValueError("privilege cannot be empty")
117
+ self.tx.execute(
118
+ f"GRANT {privilege} ON {self.qualified_name} TO {_grantee_sql(grantee)}"
119
+ )
120
+
121
+ def ensure(
122
+ self,
123
+ select_sql: str | None = None,
124
+ *,
125
+ replace_existing: bool = False,
126
+ grants: list[Grant] | None = None,
127
+ grant_public_select: bool = True,
128
+ ) -> None:
129
+ """Ensure the view exists and has required grants.
130
+
131
+ - If the view does not exist, it is created using select_sql (required).
132
+ - If it exists, it is not modified unless replace_existing=True.
133
+ - Missing privileges are granted.
134
+ """
135
+
136
+ exists = self.exists()
137
+
138
+ if not exists:
139
+ if select_sql is None:
140
+ raise ValueError("select_sql is required when creating a missing view")
141
+ self.create_or_replace(select_sql)
142
+ elif replace_existing and select_sql is not None:
143
+ self.create_or_replace(select_sql)
144
+
145
+ desired_grants: list[Grant] = []
146
+ if grant_public_select:
147
+ desired_grants.append(Grant("SELECT", "PUBLIC"))
148
+ if grants:
149
+ desired_grants.extend(grants)
150
+
151
+ for g in desired_grants:
152
+ privilege = str(g.privilege).upper().strip()
153
+ grantee = str(g.grantee).strip()
154
+ if not privilege or not grantee:
155
+ continue
156
+
157
+ if not self._has_privilege(grantee, privilege):
158
+ self.grant(privilege=privilege, grantee=grantee)
@@ -1412,14 +1412,14 @@ class SQL(BaseSQLDialect):
1412
1412
  col_type = "TIMESTAMP"
1413
1413
  sql.append(
1414
1414
  f"ALTER TABLE {TableHelper.quote(table)} "
1415
- f"ADD {TableHelper.quote(col_name_clean)} {col_type} {null_clause};"
1415
+ f"ADD COLUMN IF NOT EXISTS {TableHelper.quote(col_name_clean)} {col_type} {null_clause};"
1416
1416
  )
1417
1417
  else:
1418
1418
  # Normal code path: rely on your `TYPES.get_type(...)` logic
1419
1419
  col_type = TYPES.get_type(val)
1420
1420
  sql.append(
1421
1421
  f"ALTER TABLE {TableHelper.quote(table)} "
1422
- f"ADD {TableHelper.quote(col_name_clean)} {col_type} {null_clause};"
1422
+ f"ADD COLUMN IF NOT EXISTS {TableHelper.quote(col_name_clean)} {col_type} {null_clause};"
1423
1423
  )
1424
1424
 
1425
1425
  final_sql = sqlparse.format(" ".join(sql), reindent=True, keyword_case="upper")
@@ -0,0 +1,97 @@
1
+ import unittest
2
+ from types import SimpleNamespace
3
+
4
+ from velocity.db.core.view import View
5
+
6
+
7
+ class FakeResult:
8
+ def __init__(self, rows):
9
+ self._rows = rows
10
+
11
+ def as_dict(self):
12
+ return self
13
+
14
+ def all(self):
15
+ return list(self._rows)
16
+
17
+
18
+ class FakeTx:
19
+ def __init__(self):
20
+ self.executed = []
21
+ self.engine = SimpleNamespace(
22
+ schema_locked=False,
23
+ sql=SimpleNamespace(default_schema="public"),
24
+ )
25
+
26
+ # Toggleable responses
27
+ self._view_exists = False
28
+ self._has_priv = False
29
+
30
+ def execute(self, sql, parms=None, *args, **kwargs):
31
+ self.executed.append((sql, parms))
32
+
33
+ text = str(sql)
34
+ if "information_schema.views" in text:
35
+ return FakeResult([{"ok": True}]) if self._view_exists else FakeResult([])
36
+
37
+ if "has_table_privilege" in text:
38
+ return FakeResult([{"ok": bool(self._has_priv)}])
39
+
40
+ return FakeResult([])
41
+
42
+
43
+ class TestViewHelper(unittest.TestCase):
44
+ def test_ensure_creates_missing_view_and_grants(self):
45
+ tx = FakeTx()
46
+ v = View(tx, "my_view")
47
+
48
+ tx._view_exists = False
49
+ tx._has_priv = False
50
+
51
+ v.ensure("select 1 as one")
52
+
53
+ statements = "\n".join(s for s, _ in tx.executed)
54
+ self.assertIn("CREATE OR REPLACE VIEW", statements)
55
+ self.assertIn('"public"."my_view"', statements)
56
+ self.assertIn("GRANT SELECT ON", statements)
57
+
58
+ def test_ensure_existing_view_does_not_replace_by_default(self):
59
+ tx = FakeTx()
60
+ v = View(tx, "my_view")
61
+
62
+ tx._view_exists = True
63
+ tx._has_priv = False
64
+
65
+ v.ensure("select 1 as one")
66
+
67
+ statements = "\n".join(s for s, _ in tx.executed)
68
+ self.assertNotIn("CREATE OR REPLACE VIEW", statements)
69
+ self.assertIn("GRANT SELECT ON", statements)
70
+
71
+ def test_ensure_existing_view_replaces_when_requested(self):
72
+ tx = FakeTx()
73
+ v = View(tx, "my_view")
74
+
75
+ tx._view_exists = True
76
+ tx._has_priv = True
77
+
78
+ v.ensure("select 1 as one", replace_existing=True)
79
+
80
+ statements = "\n".join(s for s, _ in tx.executed)
81
+ self.assertIn("CREATE OR REPLACE VIEW", statements)
82
+
83
+ def test_ensure_skips_grant_if_already_present(self):
84
+ tx = FakeTx()
85
+ v = View(tx, "my_view")
86
+
87
+ tx._view_exists = True
88
+ tx._has_priv = True
89
+
90
+ v.ensure("select 1 as one")
91
+
92
+ statements = "\n".join(s for s, _ in tx.executed)
93
+ self.assertNotIn("GRANT SELECT ON", statements)
94
+
95
+
96
+ if __name__ == "__main__":
97
+ unittest.main()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: velocity-python
3
- Version: 0.0.202
3
+ Version: 0.0.204
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
@@ -42,6 +42,7 @@ src/velocity/db/core/row.py
42
42
  src/velocity/db/core/sequence.py
43
43
  src/velocity/db/core/table.py
44
44
  src/velocity/db/core/transaction.py
45
+ src/velocity/db/core/view.py
45
46
  src/velocity/db/servers/__init__.py
46
47
  src/velocity/db/servers/tablehelper.py
47
48
  src/velocity/db/servers/base/__init__.py
@@ -83,6 +84,7 @@ src/velocity/db/tests/test_schema_locking_initializers.py
83
84
  src/velocity/db/tests/test_schema_locking_simple.py
84
85
  src/velocity/db/tests/test_sql_builder.py
85
86
  src/velocity/db/tests/test_tablehelper.py
87
+ src/velocity/db/tests/test_view_helper.py
86
88
  src/velocity/db/tests/postgres/__init__.py
87
89
  src/velocity/db/tests/postgres/common.py
88
90
  src/velocity/db/tests/postgres/test_column.py
@@ -5,6 +5,7 @@ from velocity.db.core.table import Table
5
5
  from velocity.db.servers.postgres import types as postgres_types
6
6
  from velocity.db.servers.mysql import types as mysql_types
7
7
  from velocity.db.servers.sqlserver import types as sqlserver_types
8
+ from velocity.db.servers.postgres.sql import SQL as PostgresSQL
8
9
 
9
10
 
10
11
  class RecordingSQL:
@@ -97,6 +98,10 @@ class FakeTable(Table):
97
98
 
98
99
 
99
100
  class TableAlterTests(unittest.TestCase):
101
+ def test_postgres_alter_add_is_idempotent(self):
102
+ sql, _vals = PostgresSQL.alter_add("aws_api_activity", {"user_id": str})
103
+ self.assertIn("ADD COLUMN IF NOT EXISTS", sql.upper())
104
+
100
105
  def test_postgres_type_change_uses_python_type(self):
101
106
  sql = RecordingSQL("PostGreSQL", postgres_types)
102
107
  table = FakeTable(