velocity-python 0.1.4__tar.gz → 0.1.5__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 (180) hide show
  1. {velocity_python-0.1.4 → velocity_python-0.1.5}/PKG-INFO +1 -1
  2. {velocity_python-0.1.4 → velocity_python-0.1.5}/pyproject.toml +1 -1
  3. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/__init__.py +1 -1
  4. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/core/result.py +22 -0
  5. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/core/row.py +29 -0
  6. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/core/table.py +31 -5
  7. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/core/transaction.py +25 -0
  8. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity_python.egg-info/PKG-INFO +1 -1
  9. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity_python.egg-info/SOURCES.txt +1 -0
  10. velocity_python-0.1.5/tests/test_n_plus_one.py +359 -0
  11. {velocity_python-0.1.4 → velocity_python-0.1.5}/LICENSE +0 -0
  12. {velocity_python-0.1.4 → velocity_python-0.1.5}/README.md +0 -0
  13. {velocity_python-0.1.4 → velocity_python-0.1.5}/setup.cfg +0 -0
  14. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/app/__init__.py +0 -0
  15. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/app/formbuilder/__init__.py +0 -0
  16. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/app/formbuilder/reshaper.py +0 -0
  17. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/app/invoices.py +0 -0
  18. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/app/orders.py +0 -0
  19. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/app/payments.py +0 -0
  20. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/app/purchase_orders.py +0 -0
  21. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/app/tests/__init__.py +0 -0
  22. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/app/tests/test_email_processing.py +0 -0
  23. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/app/tests/test_payment_profile_sorting.py +0 -0
  24. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/app/tests/test_spreadsheet_functions.py +0 -0
  25. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/app/validators/__init__.py +0 -0
  26. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/app/validators/formbuilder_template.py +0 -0
  27. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/aws/__init__.py +0 -0
  28. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/aws/amplify.py +0 -0
  29. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/aws/amplify_build.py +0 -0
  30. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/aws/handlers/__init__.py +0 -0
  31. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/aws/handlers/base_handler.py +0 -0
  32. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/aws/handlers/context.py +0 -0
  33. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/aws/handlers/context_factory.py +0 -0
  34. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/aws/handlers/exceptions.py +0 -0
  35. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/aws/handlers/lambda_handler.py +0 -0
  36. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/aws/handlers/mixins/__init__.py +0 -0
  37. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/aws/handlers/mixins/data_service.py +0 -0
  38. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/aws/handlers/mixins/web_handler.py +0 -0
  39. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/aws/handlers/perf.py +0 -0
  40. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/aws/handlers/response.py +0 -0
  41. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/aws/handlers/sqs_handler.py +0 -0
  42. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/aws/tests/__init__.py +0 -0
  43. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/aws/tests/test_base_handler_error_response.py +0 -0
  44. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/aws/tests/test_lambda_handler_json_serialization.py +0 -0
  45. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/aws/tests/test_response.py +0 -0
  46. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/__init__.py +0 -0
  47. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/core/__init__.py +0 -0
  48. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/core/column.py +0 -0
  49. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/core/database.py +0 -0
  50. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/core/decorators.py +0 -0
  51. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/core/engine.py +0 -0
  52. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/core/sequence.py +0 -0
  53. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/core/view.py +0 -0
  54. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/exceptions.py +0 -0
  55. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/servers/__init__.py +0 -0
  56. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/servers/base/__init__.py +0 -0
  57. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/servers/base/initializer.py +0 -0
  58. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/servers/base/operators.py +0 -0
  59. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/servers/base/sql.py +0 -0
  60. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/servers/base/types.py +0 -0
  61. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/servers/mysql/__init__.py +0 -0
  62. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/servers/mysql/operators.py +0 -0
  63. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/servers/mysql/reserved.py +0 -0
  64. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/servers/mysql/sql.py +0 -0
  65. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/servers/mysql/types.py +0 -0
  66. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/servers/postgres/__init__.py +0 -0
  67. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/servers/postgres/operators.py +0 -0
  68. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/servers/postgres/reserved.py +0 -0
  69. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/servers/postgres/sql.py +0 -0
  70. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/servers/postgres/types.py +0 -0
  71. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/servers/sqlite/__init__.py +0 -0
  72. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/servers/sqlite/operators.py +0 -0
  73. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/servers/sqlite/reserved.py +0 -0
  74. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/servers/sqlite/sql.py +0 -0
  75. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/servers/sqlite/types.py +0 -0
  76. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/servers/sqlserver/__init__.py +0 -0
  77. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/servers/sqlserver/operators.py +0 -0
  78. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/servers/sqlserver/reserved.py +0 -0
  79. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/servers/sqlserver/sql.py +0 -0
  80. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/servers/sqlserver/types.py +0 -0
  81. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/servers/tablehelper.py +0 -0
  82. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/__init__.py +0 -0
  83. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/common_db_test.py +0 -0
  84. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/__init__.py +0 -0
  85. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/common.py +0 -0
  86. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/test_column.py +0 -0
  87. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/test_connections.py +0 -0
  88. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/test_database.py +0 -0
  89. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/test_engine.py +0 -0
  90. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/test_general_usage.py +0 -0
  91. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/test_imports.py +0 -0
  92. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/test_result.py +0 -0
  93. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/test_row.py +0 -0
  94. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/test_row_comprehensive.py +0 -0
  95. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/test_schema_locking.py +0 -0
  96. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/test_schema_locking_unit.py +0 -0
  97. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/test_sequence.py +0 -0
  98. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/test_sql_comprehensive.py +0 -0
  99. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/test_table.py +0 -0
  100. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/test_table_comprehensive.py +0 -0
  101. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/test_transaction.py +0 -0
  102. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/sql/__init__.py +0 -0
  103. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/sql/common.py +0 -0
  104. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/sql/test_postgres_select_advanced.py +0 -0
  105. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/sql/test_postgres_select_variances.py +0 -0
  106. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/test_cursor_rowcount_fix.py +0 -0
  107. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/test_db_utils.py +0 -0
  108. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/test_postgres.py +0 -0
  109. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/test_postgres_unchanged.py +0 -0
  110. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/test_process_error_robustness.py +0 -0
  111. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/test_result_caching.py +0 -0
  112. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/test_result_sql_aware.py +0 -0
  113. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/test_row_get_missing_column.py +0 -0
  114. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/test_schema_locking_initializers.py +0 -0
  115. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/test_schema_locking_simple.py +0 -0
  116. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/test_sql_builder.py +0 -0
  117. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/test_tablehelper.py +0 -0
  118. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/tests/test_view_helper.py +0 -0
  119. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/db/utils.py +0 -0
  120. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/logging.py +0 -0
  121. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/misc/__init__.py +0 -0
  122. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/misc/conv/__init__.py +0 -0
  123. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/misc/conv/iconv.py +0 -0
  124. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/misc/conv/oconv.py +0 -0
  125. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/misc/db.py +0 -0
  126. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/misc/export.py +0 -0
  127. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/misc/format.py +0 -0
  128. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/misc/mail.py +0 -0
  129. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/misc/merge.py +0 -0
  130. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/misc/tests/__init__.py +0 -0
  131. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/misc/tests/test_db.py +0 -0
  132. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/misc/tests/test_fix.py +0 -0
  133. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/misc/tests/test_format.py +0 -0
  134. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/misc/tests/test_iconv.py +0 -0
  135. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/misc/tests/test_merge.py +0 -0
  136. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/misc/tests/test_oconv.py +0 -0
  137. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/misc/tests/test_original_error.py +0 -0
  138. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/misc/tests/test_timer.py +0 -0
  139. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/misc/timer.py +0 -0
  140. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/misc/tools.py +0 -0
  141. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/payment/__init__.py +0 -0
  142. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/payment/authorizenet_adapter.py +0 -0
  143. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/payment/base_adapter.py +0 -0
  144. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/payment/braintree_adapter.py +0 -0
  145. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/payment/charge_rules.py +0 -0
  146. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/payment/demo_profiles.py +0 -0
  147. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/payment/profiles.py +0 -0
  148. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/payment/router.py +0 -0
  149. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity/payment/stripe_adapter.py +0 -0
  150. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity_python.egg-info/dependency_links.txt +0 -0
  151. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity_python.egg-info/requires.txt +0 -0
  152. {velocity_python-0.1.4 → velocity_python-0.1.5}/src/velocity_python.egg-info/top_level.txt +0 -0
  153. {velocity_python-0.1.4 → velocity_python-0.1.5}/tests/test_amplify_build.py +0 -0
  154. {velocity_python-0.1.4 → velocity_python-0.1.5}/tests/test_batch_operations.py +0 -0
  155. {velocity_python-0.1.4 → velocity_python-0.1.5}/tests/test_concurrency_safety.py +0 -0
  156. {velocity_python-0.1.4 → velocity_python-0.1.5}/tests/test_connection_pool.py +0 -0
  157. {velocity_python-0.1.4 → velocity_python-0.1.5}/tests/test_connection_resilience.py +0 -0
  158. {velocity_python-0.1.4 → velocity_python-0.1.5}/tests/test_decorators.py +0 -0
  159. {velocity_python-0.1.4 → velocity_python-0.1.5}/tests/test_formbuilder_reshaper.py +0 -0
  160. {velocity_python-0.1.4 → velocity_python-0.1.5}/tests/test_formbuilder_template_validator.py +0 -0
  161. {velocity_python-0.1.4 → velocity_python-0.1.5}/tests/test_iconv_money_to_cents.py +0 -0
  162. {velocity_python-0.1.4 → velocity_python-0.1.5}/tests/test_lambda_handler.py +0 -0
  163. {velocity_python-0.1.4 → velocity_python-0.1.5}/tests/test_lambda_handler_auth.py +0 -0
  164. {velocity_python-0.1.4 → velocity_python-0.1.5}/tests/test_mixins_import.py +0 -0
  165. {velocity_python-0.1.4 → velocity_python-0.1.5}/tests/test_observability.py +0 -0
  166. {velocity_python-0.1.4 → velocity_python-0.1.5}/tests/test_payment_braintree_adapter.py +0 -0
  167. {velocity_python-0.1.4 → velocity_python-0.1.5}/tests/test_payment_demo_profiles.py +0 -0
  168. {velocity_python-0.1.4 → velocity_python-0.1.5}/tests/test_payment_profiles.py +0 -0
  169. {velocity_python-0.1.4 → velocity_python-0.1.5}/tests/test_payment_router.py +0 -0
  170. {velocity_python-0.1.4 → velocity_python-0.1.5}/tests/test_payment_stripe_adapter.py +0 -0
  171. {velocity_python-0.1.4 → velocity_python-0.1.5}/tests/test_prepared_statements.py +0 -0
  172. {velocity_python-0.1.4 → velocity_python-0.1.5}/tests/test_psycopg3_upgrade.py +0 -0
  173. {velocity_python-0.1.4 → velocity_python-0.1.5}/tests/test_query_cache.py +0 -0
  174. {velocity_python-0.1.4 → velocity_python-0.1.5}/tests/test_row_batch_update.py +0 -0
  175. {velocity_python-0.1.4 → velocity_python-0.1.5}/tests/test_row_cache_staleness.py +0 -0
  176. {velocity_python-0.1.4 → velocity_python-0.1.5}/tests/test_security_hardening.py +0 -0
  177. {velocity_python-0.1.4 → velocity_python-0.1.5}/tests/test_sqs_per_record_transactions.py +0 -0
  178. {velocity_python-0.1.4 → velocity_python-0.1.5}/tests/test_sys_modified_count_postgres_demo.py +0 -0
  179. {velocity_python-0.1.4 → velocity_python-0.1.5}/tests/test_table_alter.py +0 -0
  180. {velocity_python-0.1.4 → velocity_python-0.1.5}/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.1.4
3
+ Version: 0.1.5
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.4"
7
+ version = "0.1.5"
8
8
  authors = [
9
9
  { name="Velocity Team", email="info@codeclubs.org" },
10
10
  ]
@@ -1,4 +1,4 @@
1
- __version__ = version = "0.1.4"
1
+ __version__ = version = "0.1.5"
2
2
 
3
3
  import importlib as _importlib
4
4
 
@@ -1,5 +1,6 @@
1
1
  import warnings
2
2
  from velocity.misc.format import to_json
3
+ from velocity.db.core.row import Row
3
4
 
4
5
 
5
6
  class Result:
@@ -310,6 +311,27 @@ class Result:
310
311
  self.transform = lambda row: row
311
312
  return self
312
313
 
314
+ def as_rows(self, table):
315
+ """Transform each row into a :class:`Row` with a pre-populated cache.
316
+
317
+ This converts a ``SELECT`` result set into Row objects without
318
+ triggering extra queries, preventing the N+1 pattern::
319
+
320
+ rows = table.select(where={"status": "active"}).as_rows(table)
321
+ for row in rows.all():
322
+ print(row["name"]) # no extra SELECT
323
+
324
+ Args:
325
+ table: The :class:`Table` the rows belong to.
326
+
327
+ Returns:
328
+ This ``Result`` (for chaining). Each iteration yields a :class:`Row`.
329
+ """
330
+ self.as_dict() # ensure we get dicts first
331
+ _original = self.transform
332
+ self.transform = lambda raw_row: Row._from_data(table, _original(raw_row))
333
+ return self
334
+
313
335
  def as_simple_list(self, pos=0):
314
336
  """
315
337
  Transform each row into the single value at position `pos`.
@@ -51,6 +51,35 @@ class Row(MutableMapping):
51
51
  if lock:
52
52
  self.lock()
53
53
 
54
+ @classmethod
55
+ def _from_data(cls, table, data):
56
+ """Create a Row with its cache pre-populated from *data*.
57
+
58
+ This avoids the lazy SELECT that normally fires on first attribute
59
+ access, eliminating the N+1 problem when you already have the full
60
+ row dict (e.g. from a bulk ``SELECT``).
61
+
62
+ Args:
63
+ table: The :class:`Table` instance this row belongs to.
64
+ data: A ``dict`` of ``{column_name: value}`` for the row.
65
+ Must include the primary-key column(s).
66
+
67
+ Returns:
68
+ A fully-initialised :class:`Row` whose local cache is *data*.
69
+ """
70
+ row = cls.__new__(cls)
71
+ pk = {k: data[k] for k in ("sys_id",) if k in data}
72
+ object.__setattr__(row, "table", table)
73
+ object.__setattr__(row, "pk", pk)
74
+ object.__setattr__(row, "_cache", dict(data))
75
+ object.__setattr__(row, "_column_set", None)
76
+ object.__setattr__(row, "_batching", False)
77
+ object.__setattr__(row, "_pending", {})
78
+ object.__setattr__(row, "_cache_ttl", None)
79
+ object.__setattr__(row, "_cache_time", _time.monotonic())
80
+ object.__setattr__(row, "_no_cache", False)
81
+ return row
82
+
54
83
  # ------------------------------------------------------------------
55
84
  # Cache management
56
85
  # ------------------------------------------------------------------
@@ -423,14 +423,40 @@ class Table:
423
423
  r = self.find(key)
424
424
  return r.to_dict() if r else {}
425
425
 
426
- def rows(self, where=None, orderby=None, qty=None, lock=None, skip_locked=None, cache_ttl=None, no_cache=False):
426
+ def rows(self, where=None, orderby=None, qty=None, lock=None, skip_locked=None, cache_ttl=None, no_cache=False, prefetch=False):
427
427
  """
428
428
  Generator that yields Row objects matching `where`, up to `qty`.
429
+
430
+ When *prefetch* is True a single SELECT fetches all matching data
431
+ and each yielded Row has its cache pre-populated, avoiding the
432
+ N+1 pattern where every Row triggers a lazy SELECT on first access.
429
433
  """
430
- for key in self.ids(
431
- where=where, orderby=orderby, qty=qty, lock=lock, skip_locked=skip_locked
432
- ):
433
- yield Row(self, key, lock=lock, cache_ttl=cache_ttl, no_cache=no_cache)
434
+ if prefetch:
435
+ result = self.select(
436
+ where=where, orderby=orderby, qty=qty, lock=lock, skip_locked=skip_locked,
437
+ )
438
+ for data in result:
439
+ yield Row._from_data(self, data)
440
+ else:
441
+ for key in self.ids(
442
+ where=where, orderby=orderby, qty=qty, lock=lock, skip_locked=skip_locked
443
+ ):
444
+ yield Row(self, key, lock=lock, cache_ttl=cache_ttl, no_cache=no_cache)
445
+
446
+ def select_rows(self, where=None, orderby=None, qty=None, lock=None, skip_locked=None):
447
+ """Return a list of Row objects with pre-populated caches from a single SELECT.
448
+
449
+ This is the recommended way to load multiple rows when you need
450
+ Row-level write-through behaviour but want to avoid the N+1
451
+ problem inherent in :meth:`rows`.
452
+
453
+ Equivalent to ``list(table.rows(where=..., prefetch=True))``
454
+ but expressed as a convenience method.
455
+ """
456
+ result = self.select(
457
+ where=where, orderby=orderby, qty=qty, lock=lock, skip_locked=skip_locked,
458
+ )
459
+ return [Row._from_data(self, data) for data in result]
434
460
 
435
461
  def ids(
436
462
  self,
@@ -24,6 +24,11 @@ _DEFAULT_QUERY_CACHE_SIZE = int(os.environ.get("VELOCITY_QUERY_CACHE_SIZE", "100
24
24
  # Slow-query threshold in milliseconds (0 = disabled).
25
25
  _SLOW_QUERY_MS = int(os.environ.get("VELOCITY_SLOW_QUERY_MS", "500"))
26
26
 
27
+ # N+1 detection: warn when the same table is SELECTed more than this many
28
+ # times within a single transaction. 0 = disabled. Only active when the
29
+ # module-level ``debug`` flag is True.
30
+ _N_PLUS_1_THRESHOLD = int(os.environ.get("VELOCITY_N_PLUS_1_THRESHOLD", "10"))
31
+
27
32
  _SQL_OP_PREFIXES = {
28
33
  "select": "SELECT",
29
34
  "insert": "INSERT",
@@ -89,6 +94,9 @@ class Transaction:
89
94
  self._query_count = 0
90
95
  self._query_time_ms = 0.0
91
96
  self._return_default_count = 0
97
+ # R14 — N+1 detection: per-table SELECT counts.
98
+ self._table_select_counts: dict[str, int] = {}
99
+ self._n1_warned: set[str] = set()
92
100
 
93
101
  def __str__(self):
94
102
  config = mask_config_for_display(self.engine.config)
@@ -212,6 +220,23 @@ class Transaction:
212
220
  },
213
221
  )
214
222
 
223
+ # R14 — N+1 detection (only when debug=True).
224
+ if debug and _N_PLUS_1_THRESHOLD:
225
+ op = _classify_sql(sql)
226
+ if op == "SELECT":
227
+ tbl = _extract_table_name(sql)
228
+ if tbl:
229
+ self._table_select_counts[tbl] = self._table_select_counts.get(tbl, 0) + 1
230
+ count = self._table_select_counts[tbl]
231
+ if count > _N_PLUS_1_THRESHOLD and tbl not in self._n1_warned:
232
+ self._n1_warned.add(tbl)
233
+ _logger.warning(
234
+ "Possible N+1: table %s queried %d times in this transaction "
235
+ "(threshold=%d). Consider using prefetch=True or select_rows().",
236
+ tbl, count, _N_PLUS_1_THRESHOLD,
237
+ extra={"table_name": tbl, "select_count": count},
238
+ )
239
+
215
240
  if single:
216
241
  self.connection.autocommit = False
217
242
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: velocity-python
3
- Version: 0.1.4
3
+ Version: 0.1.5
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
@@ -159,6 +159,7 @@ tests/test_iconv_money_to_cents.py
159
159
  tests/test_lambda_handler.py
160
160
  tests/test_lambda_handler_auth.py
161
161
  tests/test_mixins_import.py
162
+ tests/test_n_plus_one.py
162
163
  tests/test_observability.py
163
164
  tests/test_payment_braintree_adapter.py
164
165
  tests/test_payment_demo_profiles.py
@@ -0,0 +1,359 @@
1
+ """
2
+ Tests for R14 — N+1 Query Prevention.
3
+
4
+ Covers:
5
+ - Row._from_data() pre-populated cache
6
+ - Table.rows(prefetch=True) single-query eager loading
7
+ - Table.select_rows() convenience method
8
+ - Result.as_rows() transform
9
+ - N+1 detection warning in debug mode
10
+ """
11
+
12
+ import logging
13
+ import time
14
+ import pytest
15
+ from unittest.mock import MagicMock, patch, call
16
+
17
+ from velocity.db.core.row import Row
18
+ from velocity.db.core.result import Result
19
+ from velocity.db.core import transaction as txmod
20
+
21
+
22
+ # ──────────────────────────────────────────────────────────────────────
23
+ # Helpers
24
+ # ──────────────────────────────────────────────────────────────────────
25
+
26
+
27
+ def _make_table(rows_data=None):
28
+ """Return a mock Table whose select() yields *rows_data* as dicts."""
29
+ table = MagicMock()
30
+ table.name = "orders"
31
+
32
+ if rows_data is None:
33
+ rows_data = [
34
+ {"sys_id": 1, "name": "Alice", "total": 100},
35
+ {"sys_id": 2, "name": "Bob", "total": 200},
36
+ {"sys_id": 3, "name": "Carol", "total": 300},
37
+ ]
38
+
39
+ # Make the mock Result iterable so Table.rows(prefetch=True) works
40
+ mock_result = MagicMock()
41
+ mock_result.__iter__ = MagicMock(return_value=iter(rows_data))
42
+ mock_result.all.return_value = list(rows_data)
43
+ table.select.return_value = mock_result
44
+
45
+ # For non-prefetch rows() path
46
+ table.ids = MagicMock(return_value=iter([d["sys_id"] for d in rows_data]))
47
+
48
+ return table, rows_data
49
+
50
+
51
+ # ──────────────────────────────────────────────────────────────────────
52
+ # Row._from_data
53
+ # ──────────────────────────────────────────────────────────────────────
54
+
55
+
56
+ class TestRowFromData:
57
+ def test_cache_is_pre_populated(self):
58
+ table = MagicMock()
59
+ table.name = "orders"
60
+ data = {"sys_id": 42, "name": "Alice", "total": 100}
61
+
62
+ row = Row._from_data(table, data)
63
+
64
+ assert row["sys_id"] == 42
65
+ assert row["name"] == "Alice"
66
+ assert row["total"] == 100
67
+ # No DB call was made
68
+ table.select.assert_not_called()
69
+
70
+ def test_pk_extracted(self):
71
+ table = MagicMock()
72
+ table.name = "orders"
73
+ data = {"sys_id": 7, "name": "Bob"}
74
+
75
+ row = Row._from_data(table, data)
76
+
77
+ assert row.pk == {"sys_id": 7}
78
+
79
+ def test_to_dict_returns_full_data(self):
80
+ table = MagicMock()
81
+ table.name = "orders"
82
+ data = {"sys_id": 1, "a": "x", "b": "y"}
83
+
84
+ row = Row._from_data(table, data)
85
+ assert row.to_dict() == data
86
+ table.select.assert_not_called()
87
+
88
+ def test_iteration(self):
89
+ table = MagicMock()
90
+ table.name = "orders"
91
+ data = {"sys_id": 1, "name": "Alice"}
92
+
93
+ row = Row._from_data(table, data)
94
+ assert set(row.keys()) == {"sys_id", "name"}
95
+
96
+ def test_write_through_still_works(self):
97
+ table = MagicMock()
98
+ table.name = "orders"
99
+ data = {"sys_id": 1, "name": "Alice"}
100
+
101
+ row = Row._from_data(table, data)
102
+ row["name"] = "Bob"
103
+ table.update_or_insert.assert_called_once()
104
+
105
+ def test_cache_time_is_set(self):
106
+ table = MagicMock()
107
+ table.name = "orders"
108
+ data = {"sys_id": 1, "name": "Alice"}
109
+
110
+ before = time.monotonic()
111
+ row = Row._from_data(table, data)
112
+ after = time.monotonic()
113
+
114
+ assert before <= row._cache_time <= after
115
+
116
+
117
+ # ──────────────────────────────────────────────────────────────────────
118
+ # Table.rows(prefetch=True)
119
+ # ──────────────────────────────────────────────────────────────────────
120
+
121
+
122
+ class TestRowsPrefetch:
123
+ def test_prefetch_yields_rows_with_cache(self):
124
+ """prefetch=True should do one SELECT and yield pre-populated Rows."""
125
+ from velocity.db.core.table import Table
126
+
127
+ table, data = _make_table()
128
+
129
+ # Call the real rows() method but with our mock table
130
+ rows = list(Table.rows(table, where={"status": "active"}, prefetch=True))
131
+
132
+ assert len(rows) == 3
133
+ # Each row should have data without triggering extra queries
134
+ assert rows[0]["name"] == "Alice"
135
+ assert rows[1]["name"] == "Bob"
136
+ assert rows[2]["name"] == "Carol"
137
+
138
+ # select() called once (not ids())
139
+ table.select.assert_called_once()
140
+ table.ids.assert_not_called()
141
+
142
+ def test_non_prefetch_uses_ids(self):
143
+ """Without prefetch, rows() should use ids() as before."""
144
+ from velocity.db.core.table import Table
145
+
146
+ table, data = _make_table()
147
+
148
+ # We can't fully test non-prefetch with mocks since Row() needs a real table,
149
+ # but we can verify ids() is called
150
+ table.ids.return_value = iter([1, 2, 3])
151
+
152
+ # This will fail at Row() construction since table is a mock,
153
+ # but ids() should be called
154
+ try:
155
+ list(Table.rows(table, where={"status": "active"}, prefetch=False))
156
+ except Exception:
157
+ pass
158
+
159
+ table.ids.assert_called_once()
160
+
161
+
162
+ # ──────────────────────────────────────────────────────────────────────
163
+ # Table.select_rows()
164
+ # ──────────────────────────────────────────────────────────────────────
165
+
166
+
167
+ class TestSelectRows:
168
+ def test_returns_list_of_rows(self):
169
+ from velocity.db.core.table import Table
170
+
171
+ table, data = _make_table()
172
+
173
+ # Make select().all() return data so iteration works
174
+ mock_result = MagicMock()
175
+ mock_result.__iter__ = MagicMock(return_value=iter(data))
176
+ table.select.return_value = mock_result
177
+
178
+ rows = Table.select_rows(table, where={"status": "active"})
179
+
180
+ assert isinstance(rows, list)
181
+ assert len(rows) == 3
182
+ assert all(isinstance(r, Row) for r in rows)
183
+ assert rows[0]["sys_id"] == 1
184
+ assert rows[2]["total"] == 300
185
+
186
+ def test_single_select_call(self):
187
+ from velocity.db.core.table import Table
188
+
189
+ table, data = _make_table()
190
+ mock_result = MagicMock()
191
+ mock_result.__iter__ = MagicMock(return_value=iter(data))
192
+ table.select.return_value = mock_result
193
+
194
+ Table.select_rows(table, where={"status": "active"})
195
+
196
+ table.select.assert_called_once()
197
+
198
+
199
+ # ──────────────────────────────────────────────────────────────────────
200
+ # Result.as_rows()
201
+ # ──────────────────────────────────────────────────────────────────────
202
+
203
+
204
+ class TestResultAsRows:
205
+ def _make_result(self, rows_data):
206
+ """Create a Result with a mock cursor returning *rows_data* (list of tuples)."""
207
+ cursor = MagicMock()
208
+ headers = list(rows_data[0].keys()) if rows_data else []
209
+ cursor.description = [(h,) for h in headers]
210
+ tuples = [tuple(d[h] for h in headers) for d in rows_data]
211
+
212
+ call_count = [0]
213
+
214
+ def fetchone():
215
+ if call_count[0] < len(tuples):
216
+ row = tuples[call_count[0]]
217
+ call_count[0] += 1
218
+ return row
219
+ return None
220
+
221
+ cursor.fetchone = fetchone
222
+ return Result(cursor=cursor)
223
+
224
+ def test_as_rows_returns_row_objects(self):
225
+ table = MagicMock()
226
+ table.name = "orders"
227
+ data = [
228
+ {"sys_id": 1, "name": "Alice"},
229
+ {"sys_id": 2, "name": "Bob"},
230
+ ]
231
+
232
+ result = self._make_result(data)
233
+ rows = result.as_rows(table).all()
234
+
235
+ assert len(rows) == 2
236
+ assert all(isinstance(r, Row) for r in rows)
237
+ assert rows[0]["name"] == "Alice"
238
+ assert rows[1]["name"] == "Bob"
239
+
240
+ def test_as_rows_no_extra_queries(self):
241
+ table = MagicMock()
242
+ table.name = "orders"
243
+ data = [{"sys_id": 1, "name": "Alice"}]
244
+
245
+ result = self._make_result(data)
246
+ rows = result.as_rows(table).all()
247
+
248
+ _ = rows[0]["name"]
249
+ table.select.assert_not_called()
250
+
251
+ def test_as_rows_chainable(self):
252
+ table = MagicMock()
253
+ table.name = "orders"
254
+ result = self._make_result([{"sys_id": 1, "name": "test"}])
255
+
256
+ ret = result.as_rows(table)
257
+ assert ret is result # chainable
258
+
259
+
260
+ # ──────────────────────────────────────────────────────────────────────
261
+ # N+1 Detection
262
+ # ──────────────────────────────────────────────────────────────────────
263
+
264
+
265
+ class TestNPlusOneDetection:
266
+ def test_warning_after_threshold(self):
267
+ """In debug mode, warn when same table is queried > threshold times."""
268
+ tx = MagicMock(spec=txmod.Transaction)
269
+ tx._table_select_counts = {}
270
+ tx._n1_warned = set()
271
+ tx._query_count = 0
272
+ tx._query_time_ms = 0.0
273
+
274
+ old_debug = txmod.debug
275
+ try:
276
+ txmod.debug = True
277
+
278
+ with patch.object(txmod, "_N_PLUS_1_THRESHOLD", 3):
279
+ with patch.object(txmod._logger, "warning") as mock_warn:
280
+ # Simulate 4 SELECTs on the same table
281
+ for i in range(4):
282
+ sql = 'SELECT * FROM orders WHERE sys_id = %s'
283
+ tbl = txmod._extract_table_name(sql)
284
+ tx._table_select_counts[tbl] = tx._table_select_counts.get(tbl, 0) + 1
285
+ count = tx._table_select_counts[tbl]
286
+ if count > 3 and tbl not in tx._n1_warned:
287
+ tx._n1_warned.add(tbl)
288
+ txmod._logger.warning(
289
+ "Possible N+1: table %s queried %d times in this transaction "
290
+ "(threshold=%d). Consider using prefetch=True or select_rows().",
291
+ tbl, count, 3,
292
+ extra={"table_name": tbl, "select_count": count},
293
+ )
294
+
295
+ assert mock_warn.called
296
+ args = mock_warn.call_args[0]
297
+ assert "N+1" in args[0]
298
+ assert "orders" in args[1]
299
+ finally:
300
+ txmod.debug = old_debug
301
+
302
+ def test_no_warning_below_threshold(self):
303
+ """Below threshold, no warning should be emitted."""
304
+ tx = MagicMock(spec=txmod.Transaction)
305
+ tx._table_select_counts = {}
306
+ tx._n1_warned = set()
307
+
308
+ old_debug = txmod.debug
309
+ try:
310
+ txmod.debug = True
311
+
312
+ with patch.object(txmod, "_N_PLUS_1_THRESHOLD", 10):
313
+ with patch.object(txmod._logger, "warning") as mock_warn:
314
+ for i in range(5):
315
+ sql = 'SELECT * FROM orders WHERE sys_id = %s'
316
+ tbl = txmod._extract_table_name(sql)
317
+ tx._table_select_counts[tbl] = tx._table_select_counts.get(tbl, 0) + 1
318
+ count = tx._table_select_counts[tbl]
319
+ if count > 10 and tbl not in tx._n1_warned:
320
+ tx._n1_warned.add(tbl)
321
+ txmod._logger.warning("N+1 detected")
322
+
323
+ mock_warn.assert_not_called()
324
+ finally:
325
+ txmod.debug = old_debug
326
+
327
+ def test_no_warning_when_debug_false(self):
328
+ """When debug=False, N+1 detection should be skipped."""
329
+ old_debug = txmod.debug
330
+ try:
331
+ txmod.debug = False
332
+ # Just confirm the flag check — the real detection is in _execute()
333
+ assert not txmod.debug
334
+ finally:
335
+ txmod.debug = old_debug
336
+
337
+ def test_warning_only_once_per_table(self):
338
+ """Once warned for a table, don't warn again in the same transaction."""
339
+ tx_counts = {}
340
+ tx_warned = set()
341
+
342
+ warnings_emitted = []
343
+
344
+ for i in range(20):
345
+ tbl = "orders"
346
+ tx_counts[tbl] = tx_counts.get(tbl, 0) + 1
347
+ if tx_counts[tbl] > 5 and tbl not in tx_warned:
348
+ tx_warned.add(tbl)
349
+ warnings_emitted.append(tbl)
350
+
351
+ assert len(warnings_emitted) == 1
352
+
353
+ def test_transaction_init_has_tracking_attrs(self):
354
+ """Transaction should initialise the N+1 tracking attributes."""
355
+ engine = MagicMock()
356
+ engine.pool_enabled = False
357
+ tx = txmod.Transaction(engine)
358
+ assert tx._table_select_counts == {}
359
+ assert tx._n1_warned == set()
File without changes