velocity-python 0.1.3__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.
- {velocity_python-0.1.3 → velocity_python-0.1.5}/PKG-INFO +1 -1
- {velocity_python-0.1.3 → velocity_python-0.1.5}/pyproject.toml +1 -1
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/__init__.py +1 -1
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/core/decorators.py +6 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/core/engine.py +63 -4
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/core/result.py +22 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/core/row.py +29 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/core/table.py +31 -5
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/core/transaction.py +110 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity_python.egg-info/PKG-INFO +1 -1
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity_python.egg-info/SOURCES.txt +2 -0
- velocity_python-0.1.5/tests/test_n_plus_one.py +359 -0
- velocity_python-0.1.5/tests/test_observability.py +443 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/LICENSE +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/README.md +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/setup.cfg +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/app/__init__.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/app/formbuilder/__init__.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/app/formbuilder/reshaper.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/app/invoices.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/app/orders.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/app/payments.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/app/purchase_orders.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/app/tests/__init__.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/app/tests/test_email_processing.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/app/tests/test_payment_profile_sorting.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/app/tests/test_spreadsheet_functions.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/app/validators/__init__.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/app/validators/formbuilder_template.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/aws/__init__.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/aws/amplify.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/aws/amplify_build.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/aws/handlers/__init__.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/aws/handlers/base_handler.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/aws/handlers/context.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/aws/handlers/context_factory.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/aws/handlers/exceptions.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/aws/handlers/lambda_handler.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/aws/handlers/mixins/__init__.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/aws/handlers/mixins/data_service.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/aws/handlers/mixins/web_handler.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/aws/handlers/perf.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/aws/handlers/response.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/aws/handlers/sqs_handler.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/aws/tests/__init__.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/aws/tests/test_base_handler_error_response.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/aws/tests/test_lambda_handler_json_serialization.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/aws/tests/test_response.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/__init__.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/core/__init__.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/core/column.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/core/database.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/core/sequence.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/core/view.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/exceptions.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/servers/__init__.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/servers/base/__init__.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/servers/base/initializer.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/servers/base/operators.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/servers/base/sql.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/servers/base/types.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/servers/mysql/__init__.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/servers/mysql/operators.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/servers/mysql/reserved.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/servers/mysql/sql.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/servers/mysql/types.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/servers/postgres/__init__.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/servers/postgres/operators.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/servers/postgres/reserved.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/servers/postgres/sql.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/servers/postgres/types.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/servers/sqlite/__init__.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/servers/sqlite/operators.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/servers/sqlite/reserved.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/servers/sqlite/sql.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/servers/sqlite/types.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/servers/sqlserver/__init__.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/servers/sqlserver/operators.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/servers/sqlserver/reserved.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/servers/sqlserver/sql.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/servers/sqlserver/types.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/servers/tablehelper.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/__init__.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/common_db_test.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/__init__.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/common.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/test_column.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/test_connections.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/test_database.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/test_engine.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/test_general_usage.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/test_imports.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/test_result.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/test_row.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/test_row_comprehensive.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/test_schema_locking.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/test_schema_locking_unit.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/test_sequence.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/test_sql_comprehensive.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/test_table.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/test_table_comprehensive.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/postgres/test_transaction.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/sql/__init__.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/sql/common.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/sql/test_postgres_select_advanced.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/sql/test_postgres_select_variances.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/test_cursor_rowcount_fix.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/test_db_utils.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/test_postgres.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/test_postgres_unchanged.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/test_process_error_robustness.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/test_result_caching.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/test_result_sql_aware.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/test_row_get_missing_column.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/test_schema_locking_initializers.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/test_schema_locking_simple.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/test_sql_builder.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/test_tablehelper.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/tests/test_view_helper.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/db/utils.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/logging.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/misc/__init__.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/misc/conv/__init__.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/misc/conv/iconv.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/misc/conv/oconv.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/misc/db.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/misc/export.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/misc/format.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/misc/mail.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/misc/merge.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/misc/tests/__init__.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/misc/tests/test_db.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/misc/tests/test_fix.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/misc/tests/test_format.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/misc/tests/test_iconv.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/misc/tests/test_merge.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/misc/tests/test_oconv.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/misc/tests/test_original_error.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/misc/tests/test_timer.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/misc/timer.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/misc/tools.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/payment/__init__.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/payment/authorizenet_adapter.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/payment/base_adapter.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/payment/braintree_adapter.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/payment/charge_rules.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/payment/demo_profiles.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/payment/profiles.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/payment/router.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity/payment/stripe_adapter.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity_python.egg-info/dependency_links.txt +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity_python.egg-info/requires.txt +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/src/velocity_python.egg-info/top_level.txt +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/tests/test_amplify_build.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/tests/test_batch_operations.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/tests/test_concurrency_safety.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/tests/test_connection_pool.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/tests/test_connection_resilience.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/tests/test_decorators.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/tests/test_formbuilder_reshaper.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/tests/test_formbuilder_template_validator.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/tests/test_iconv_money_to_cents.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/tests/test_lambda_handler.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/tests/test_lambda_handler_auth.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/tests/test_mixins_import.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/tests/test_payment_braintree_adapter.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/tests/test_payment_demo_profiles.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/tests/test_payment_profiles.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/tests/test_payment_router.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/tests/test_payment_stripe_adapter.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/tests/test_prepared_statements.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/tests/test_psycopg3_upgrade.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/tests/test_query_cache.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/tests/test_row_batch_update.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/tests/test_row_cache_staleness.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/tests/test_security_hardening.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/tests/test_sqs_per_record_transactions.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/tests/test_sys_modified_count_postgres_demo.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/tests/test_table_alter.py +0 -0
- {velocity_python-0.1.3 → velocity_python-0.1.5}/tests/test_where_clause_validation.py +0 -0
|
@@ -121,6 +121,12 @@ def return_default(
|
|
|
121
121
|
e,
|
|
122
122
|
)
|
|
123
123
|
|
|
124
|
+
# R12 — Increment per-transaction counter for swallowed exceptions.
|
|
125
|
+
try:
|
|
126
|
+
self.tx._return_default_count = getattr(self.tx, "_return_default_count", 0) + 1
|
|
127
|
+
except Exception:
|
|
128
|
+
pass
|
|
129
|
+
|
|
124
130
|
# Capture swallowed exceptions for upstream diagnostics.
|
|
125
131
|
# This decorator intentionally returns a default value instead of
|
|
126
132
|
# raising, but consumers (e.g. API handlers) may still want to
|
|
@@ -77,6 +77,7 @@ class ConnectionPool:
|
|
|
77
77
|
if self._closed:
|
|
78
78
|
raise exceptions.DbConnectionError("Connection pool is closed")
|
|
79
79
|
|
|
80
|
+
t0 = time.perf_counter()
|
|
80
81
|
self._available.acquire() # blocks when maxconn reached
|
|
81
82
|
|
|
82
83
|
with self._lock:
|
|
@@ -84,7 +85,11 @@ class ConnectionPool:
|
|
|
84
85
|
while self._pool:
|
|
85
86
|
conn = self._pool.pop()
|
|
86
87
|
if self._is_alive(conn):
|
|
87
|
-
|
|
88
|
+
elapsed_ms = (time.perf_counter() - t0) * 1000
|
|
89
|
+
logger.debug(
|
|
90
|
+
"Pool: reusing idle connection (pool_size=%d, wait=%.1f ms)",
|
|
91
|
+
len(self._pool), elapsed_ms,
|
|
92
|
+
)
|
|
88
93
|
return conn
|
|
89
94
|
# Dead connection — close it silently and create a fresh one.
|
|
90
95
|
logger.debug("Pool: discarding dead idle connection")
|
|
@@ -95,7 +100,11 @@ class ConnectionPool:
|
|
|
95
100
|
try:
|
|
96
101
|
conn = self._connect_fn()
|
|
97
102
|
self._total_created += 1
|
|
98
|
-
|
|
103
|
+
elapsed_ms = (time.perf_counter() - t0) * 1000
|
|
104
|
+
logger.debug(
|
|
105
|
+
"Pool: created new connection (total=%d, wait=%.1f ms)",
|
|
106
|
+
self._total_created, elapsed_ms,
|
|
107
|
+
)
|
|
99
108
|
return conn
|
|
100
109
|
except Exception:
|
|
101
110
|
self._available.release()
|
|
@@ -277,10 +286,12 @@ class Engine:
|
|
|
277
286
|
If pooling is enabled, borrows from the pool.
|
|
278
287
|
If the database is missing, tries to create it, then reconnect.
|
|
279
288
|
"""
|
|
289
|
+
t0 = time.perf_counter()
|
|
280
290
|
if self.__pool:
|
|
281
291
|
try:
|
|
282
292
|
conn = self.__pool.getconn()
|
|
283
|
-
|
|
293
|
+
elapsed_ms = (time.perf_counter() - t0) * 1000
|
|
294
|
+
logger.debug("Engine.connect: obtained pooled connection (%.1f ms)", elapsed_ms)
|
|
284
295
|
return conn
|
|
285
296
|
except exceptions.DbDatabaseMissingError:
|
|
286
297
|
self.create_database()
|
|
@@ -291,7 +302,8 @@ class Engine:
|
|
|
291
302
|
|
|
292
303
|
try:
|
|
293
304
|
conn = self._raw_connect()
|
|
294
|
-
|
|
305
|
+
elapsed_ms = (time.perf_counter() - t0) * 1000
|
|
306
|
+
logger.debug("Engine.connect: created direct connection (%.1f ms)", elapsed_ms)
|
|
295
307
|
except exceptions.DbDatabaseMissingError:
|
|
296
308
|
self.create_database()
|
|
297
309
|
conn = self._raw_connect()
|
|
@@ -496,6 +508,53 @@ class Engine:
|
|
|
496
508
|
# if depth == 0:
|
|
497
509
|
# delattr(_tx, "_exec_function_depth")
|
|
498
510
|
|
|
511
|
+
def perf_log(self, func):
|
|
512
|
+
"""
|
|
513
|
+
Decorator that logs wall time, query count, and total query time
|
|
514
|
+
for a ``@engine.transaction``-wrapped function.
|
|
515
|
+
|
|
516
|
+
Usage::
|
|
517
|
+
|
|
518
|
+
@engine.perf_log
|
|
519
|
+
@engine.transaction
|
|
520
|
+
def some_work(tx):
|
|
521
|
+
...
|
|
522
|
+
|
|
523
|
+
The ``tx`` parameter **must** be a keyword argument or the first
|
|
524
|
+
positional ``Transaction`` so we can read its counters.
|
|
525
|
+
"""
|
|
526
|
+
|
|
527
|
+
@wraps(func)
|
|
528
|
+
def wrapper(*args, **kwds):
|
|
529
|
+
t0 = time.perf_counter()
|
|
530
|
+
result = func(*args, **kwds)
|
|
531
|
+
wall_ms = (time.perf_counter() - t0) * 1000
|
|
532
|
+
|
|
533
|
+
# Find the Transaction among the arguments.
|
|
534
|
+
tx = kwds.get("tx")
|
|
535
|
+
if tx is None:
|
|
536
|
+
for a in args:
|
|
537
|
+
if isinstance(a, Transaction):
|
|
538
|
+
tx = a
|
|
539
|
+
break
|
|
540
|
+
|
|
541
|
+
qcount = getattr(tx, "_query_count", 0) if tx else 0
|
|
542
|
+
qtime = getattr(tx, "_query_time_ms", 0.0) if tx else 0.0
|
|
543
|
+
|
|
544
|
+
logger.info(
|
|
545
|
+
"perf_log %s: wall=%.1f ms, queries=%d, query_time=%.1f ms",
|
|
546
|
+
getattr(func, "__qualname__", func.__name__),
|
|
547
|
+
wall_ms, qcount, qtime,
|
|
548
|
+
extra={
|
|
549
|
+
"wall_ms": round(wall_ms, 1),
|
|
550
|
+
"query_count": qcount,
|
|
551
|
+
"query_time_ms": round(qtime, 1),
|
|
552
|
+
},
|
|
553
|
+
)
|
|
554
|
+
return result
|
|
555
|
+
|
|
556
|
+
return wrapper
|
|
557
|
+
|
|
499
558
|
@property
|
|
500
559
|
def driver(self):
|
|
501
560
|
return self.__driver
|
|
@@ -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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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,
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import logging
|
|
1
2
|
import os
|
|
3
|
+
import time as _time
|
|
2
4
|
import traceback
|
|
3
5
|
from collections import OrderedDict
|
|
4
6
|
|
|
@@ -14,9 +16,65 @@ from velocity.misc.db import randomword
|
|
|
14
16
|
|
|
15
17
|
debug = False
|
|
16
18
|
|
|
19
|
+
_logger = logging.getLogger("velocity.db.transaction")
|
|
20
|
+
|
|
17
21
|
# Default maximum number of cached query results per transaction.
|
|
18
22
|
_DEFAULT_QUERY_CACHE_SIZE = int(os.environ.get("VELOCITY_QUERY_CACHE_SIZE", "100"))
|
|
19
23
|
|
|
24
|
+
# Slow-query threshold in milliseconds (0 = disabled).
|
|
25
|
+
_SLOW_QUERY_MS = int(os.environ.get("VELOCITY_SLOW_QUERY_MS", "500"))
|
|
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
|
+
|
|
32
|
+
_SQL_OP_PREFIXES = {
|
|
33
|
+
"select": "SELECT",
|
|
34
|
+
"insert": "INSERT",
|
|
35
|
+
"update": "UPDATE",
|
|
36
|
+
"delete": "DELETE",
|
|
37
|
+
"create": "DDL",
|
|
38
|
+
"alter": "DDL",
|
|
39
|
+
"drop": "DDL",
|
|
40
|
+
"set": "SET",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _classify_sql(sql):
|
|
45
|
+
"""Return a short operation label (SELECT, INSERT, …) from a SQL string."""
|
|
46
|
+
if not sql:
|
|
47
|
+
return "OTHER"
|
|
48
|
+
first = sql.lstrip().split(None, 1)[0].lower() if sql.strip() else ""
|
|
49
|
+
return _SQL_OP_PREFIXES.get(first, "OTHER")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _extract_table_name(sql):
|
|
53
|
+
"""Best-effort extraction of the main table name from a SQL statement."""
|
|
54
|
+
if not sql:
|
|
55
|
+
return None
|
|
56
|
+
upper = sql.strip().upper()
|
|
57
|
+
lowered = sql.strip()
|
|
58
|
+
try:
|
|
59
|
+
if upper.startswith("SELECT"):
|
|
60
|
+
idx = upper.find(" FROM ")
|
|
61
|
+
if idx != -1:
|
|
62
|
+
rest = lowered[idx + 6:].strip()
|
|
63
|
+
return rest.split()[0].strip('"').strip("'") if rest else None
|
|
64
|
+
elif upper.startswith("INSERT"):
|
|
65
|
+
idx = upper.find(" INTO ")
|
|
66
|
+
if idx != -1:
|
|
67
|
+
rest = lowered[idx + 6:].strip()
|
|
68
|
+
return rest.split()[0].strip('"').strip("'") if rest else None
|
|
69
|
+
elif upper.startswith(("UPDATE", "DELETE FROM")):
|
|
70
|
+
parts = lowered.split()
|
|
71
|
+
if len(parts) >= 2:
|
|
72
|
+
token = parts[2] if upper.startswith("DELETE") and len(parts) > 2 else parts[1]
|
|
73
|
+
return token.strip('"').strip("'")
|
|
74
|
+
except (IndexError, ValueError):
|
|
75
|
+
pass
|
|
76
|
+
return None
|
|
77
|
+
|
|
20
78
|
|
|
21
79
|
class Transaction:
|
|
22
80
|
"""
|
|
@@ -32,6 +90,13 @@ class Transaction:
|
|
|
32
90
|
# R5 — Transaction-scoped query cache (opt-in via cache=True on select).
|
|
33
91
|
self.__query_cache: OrderedDict = OrderedDict()
|
|
34
92
|
self.__query_cache_max = _DEFAULT_QUERY_CACHE_SIZE
|
|
93
|
+
# R12 — Observability counters.
|
|
94
|
+
self._query_count = 0
|
|
95
|
+
self._query_time_ms = 0.0
|
|
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()
|
|
35
100
|
|
|
36
101
|
def __str__(self):
|
|
37
102
|
config = mask_config_for_display(self.engine.config)
|
|
@@ -119,6 +184,7 @@ class Transaction:
|
|
|
119
184
|
if prepare is None:
|
|
120
185
|
prepare = getattr(self.engine, "prepare_enabled", False)
|
|
121
186
|
|
|
187
|
+
t0 = _time.perf_counter()
|
|
122
188
|
try:
|
|
123
189
|
if parms:
|
|
124
190
|
cursor.execute(sql, parms, prepare=prepare)
|
|
@@ -136,6 +202,41 @@ class Transaction:
|
|
|
136
202
|
except Exception as e:
|
|
137
203
|
raise self.engine.process_error(e, sql, parms)
|
|
138
204
|
|
|
205
|
+
elapsed_ms = (_time.perf_counter() - t0) * 1000
|
|
206
|
+
self._query_count += 1
|
|
207
|
+
self._query_time_ms += elapsed_ms
|
|
208
|
+
|
|
209
|
+
# R12 — Slow query logging.
|
|
210
|
+
if _SLOW_QUERY_MS and elapsed_ms > _SLOW_QUERY_MS:
|
|
211
|
+
op = _classify_sql(sql)
|
|
212
|
+
tbl = _extract_table_name(sql)
|
|
213
|
+
_logger.warning(
|
|
214
|
+
"Slow query (%s): %.1f ms table=%s",
|
|
215
|
+
op, elapsed_ms, tbl,
|
|
216
|
+
extra={
|
|
217
|
+
"query_duration_ms": round(elapsed_ms, 1),
|
|
218
|
+
"table_name": tbl,
|
|
219
|
+
"operation": op,
|
|
220
|
+
},
|
|
221
|
+
)
|
|
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
|
+
|
|
139
240
|
if single:
|
|
140
241
|
self.connection.autocommit = False
|
|
141
242
|
|
|
@@ -195,6 +296,15 @@ class Transaction:
|
|
|
195
296
|
if debug:
|
|
196
297
|
print(f"{id(self)} --- connection commit.")
|
|
197
298
|
self.connection.commit()
|
|
299
|
+
if self._query_count:
|
|
300
|
+
_logger.debug(
|
|
301
|
+
"Transaction commit: %d queries in %.1f ms",
|
|
302
|
+
self._query_count, self._query_time_ms,
|
|
303
|
+
extra={
|
|
304
|
+
"query_count": self._query_count,
|
|
305
|
+
"query_time_ms": round(self._query_time_ms, 1),
|
|
306
|
+
},
|
|
307
|
+
)
|
|
198
308
|
|
|
199
309
|
def rollback(self):
|
|
200
310
|
"""
|
|
@@ -159,6 +159,8 @@ 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
|
|
163
|
+
tests/test_observability.py
|
|
162
164
|
tests/test_payment_braintree_adapter.py
|
|
163
165
|
tests/test_payment_demo_profiles.py
|
|
164
166
|
tests/test_payment_profiles.py
|