velocity-python 0.1.9__tar.gz → 0.1.10__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.9/src/velocity_python.egg-info → velocity_python-0.1.10}/PKG-INFO +1 -1
- {velocity_python-0.1.9 → velocity_python-0.1.10}/pyproject.toml +4 -1
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/__init__.py +1 -1
- velocity_python-0.1.10/src/velocity/db/migrations.py +579 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10/src/velocity_python.egg-info}/PKG-INFO +1 -1
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity_python.egg-info/SOURCES.txt +3 -0
- velocity_python-0.1.10/src/velocity_python.egg-info/entry_points.txt +2 -0
- velocity_python-0.1.10/tests/test_schema_migrations.py +922 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/LICENSE +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/README.md +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/setup.cfg +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/app/__init__.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/app/formbuilder/__init__.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/app/formbuilder/reshaper.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/app/invoices.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/app/orders.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/app/payments.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/app/purchase_orders.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/app/tests/__init__.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/app/tests/test_email_processing.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/app/tests/test_payment_profile_sorting.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/app/tests/test_spreadsheet_functions.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/app/validators/__init__.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/app/validators/formbuilder_template.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/aws/__init__.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/aws/amplify.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/aws/amplify_build.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/aws/handlers/__init__.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/aws/handlers/base_handler.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/aws/handlers/context.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/aws/handlers/context_factory.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/aws/handlers/exceptions.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/aws/handlers/lambda_handler.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/aws/handlers/mixins/__init__.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/aws/handlers/mixins/data_service.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/aws/handlers/mixins/web_handler.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/aws/handlers/perf.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/aws/handlers/response.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/aws/handlers/sqs_handler.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/aws/tests/__init__.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/aws/tests/test_base_handler_error_response.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/aws/tests/test_lambda_handler_json_serialization.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/aws/tests/test_response.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/__init__.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/core/__init__.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/core/async_support.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/core/column.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/core/database.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/core/decorators.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/core/engine.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/core/result.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/core/row.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/core/sequence.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/core/table.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/core/transaction.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/core/view.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/exceptions.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/servers/__init__.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/servers/base/__init__.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/servers/base/initializer.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/servers/base/operators.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/servers/base/sql.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/servers/base/types.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/servers/mysql/__init__.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/servers/mysql/operators.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/servers/mysql/reserved.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/servers/mysql/sql.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/servers/mysql/types.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/servers/postgres/__init__.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/servers/postgres/operators.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/servers/postgres/reserved.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/servers/postgres/sql.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/servers/postgres/types.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/servers/sqlite/__init__.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/servers/sqlite/operators.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/servers/sqlite/reserved.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/servers/sqlite/sql.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/servers/sqlite/types.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/servers/sqlserver/__init__.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/servers/sqlserver/operators.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/servers/sqlserver/reserved.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/servers/sqlserver/sql.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/servers/sqlserver/types.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/servers/tablehelper.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/__init__.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/common_db_test.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/postgres/__init__.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/postgres/common.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/postgres/test_column.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/postgres/test_connections.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/postgres/test_database.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/postgres/test_engine.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/postgres/test_general_usage.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/postgres/test_imports.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/postgres/test_result.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/postgres/test_row.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/postgres/test_row_comprehensive.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/postgres/test_schema_locking.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/postgres/test_schema_locking_unit.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/postgres/test_sequence.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/postgres/test_sql_comprehensive.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/postgres/test_table.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/postgres/test_table_comprehensive.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/postgres/test_transaction.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/sql/__init__.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/sql/common.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/sql/test_postgres_select_advanced.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/sql/test_postgres_select_variances.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/test_cursor_rowcount_fix.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/test_db_utils.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/test_postgres.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/test_postgres_unchanged.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/test_process_error_robustness.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/test_result_caching.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/test_result_sql_aware.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/test_row_get_missing_column.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/test_schema_locking_initializers.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/test_schema_locking_simple.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/test_sql_builder.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/test_tablehelper.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/tests/test_view_helper.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/db/utils.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/logging.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/misc/__init__.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/misc/conv/__init__.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/misc/conv/iconv.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/misc/conv/oconv.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/misc/db.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/misc/export.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/misc/format.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/misc/mail.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/misc/merge.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/misc/tests/__init__.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/misc/tests/test_db.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/misc/tests/test_fix.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/misc/tests/test_format.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/misc/tests/test_iconv.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/misc/tests/test_merge.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/misc/tests/test_oconv.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/misc/tests/test_original_error.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/misc/tests/test_timer.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/misc/timer.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/misc/tools.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/payment/__init__.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/payment/authorizenet_adapter.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/payment/base_adapter.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/payment/braintree_adapter.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/payment/charge_rules.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/payment/demo_profiles.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/payment/profiles.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/payment/router.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity/payment/stripe_adapter.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity_python.egg-info/dependency_links.txt +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity_python.egg-info/requires.txt +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/src/velocity_python.egg-info/top_level.txt +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/tests/test_amplify_build.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/tests/test_async_support.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/tests/test_batch_operations.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/tests/test_concurrency_safety.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/tests/test_connection_pool.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/tests/test_connection_resilience.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/tests/test_decorators.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/tests/test_formbuilder_reshaper.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/tests/test_formbuilder_template_validator.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/tests/test_iconv_money_to_cents.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/tests/test_lambda_handler.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/tests/test_lambda_handler_auth.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/tests/test_mixins_import.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/tests/test_n_plus_one.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/tests/test_observability.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/tests/test_payment_braintree_adapter.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/tests/test_payment_demo_profiles.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/tests/test_payment_profiles.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/tests/test_payment_router.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/tests/test_payment_stripe_adapter.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/tests/test_prepared_statements.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/tests/test_psycopg3_upgrade.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/tests/test_query_cache.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/tests/test_row_batch_update.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/tests/test_row_cache_staleness.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/tests/test_security_hardening.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/tests/test_sqs_per_record_transactions.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/tests/test_sys_modified_count_postgres_demo.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/tests/test_table_alter.py +0 -0
- {velocity_python-0.1.9 → velocity_python-0.1.10}/tests/test_where_clause_validation.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "velocity-python"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.10"
|
|
8
8
|
authors = [
|
|
9
9
|
{ name="Velocity Team", email="info@codeclubs.org" },
|
|
10
10
|
]
|
|
@@ -29,6 +29,9 @@ dependencies = [
|
|
|
29
29
|
"sqlparse>=0.5.0"
|
|
30
30
|
]
|
|
31
31
|
|
|
32
|
+
[project.scripts]
|
|
33
|
+
velocity = "velocity.db.migrations:cli"
|
|
34
|
+
|
|
32
35
|
[project.urls]
|
|
33
36
|
Homepage = "https://codeclubs.org/projects/velocity"
|
|
34
37
|
Documentation = "https://codeclubs.org/projects/velocity"
|
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Schema migration framework for velocity-python.
|
|
3
|
+
|
|
4
|
+
Provides versioned, transactional schema migrations with up/down support,
|
|
5
|
+
a tracking table (``velocity_migrations``), and a CLI interface.
|
|
6
|
+
|
|
7
|
+
Usage::
|
|
8
|
+
|
|
9
|
+
from velocity.db.migrations import migration, MigrationRunner
|
|
10
|
+
|
|
11
|
+
@migration(version=1, description="Create users table")
|
|
12
|
+
def migrate_001(tx):
|
|
13
|
+
tx.table("users").create({"name": "TEXT", "email": "TEXT"})
|
|
14
|
+
|
|
15
|
+
@migration(version=1, down=True, description="Drop users table")
|
|
16
|
+
def rollback_001(tx):
|
|
17
|
+
tx.table("users").drop()
|
|
18
|
+
|
|
19
|
+
# Apply all pending migrations
|
|
20
|
+
runner = MigrationRunner(engine)
|
|
21
|
+
runner.migrate()
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import hashlib
|
|
27
|
+
import inspect
|
|
28
|
+
import logging
|
|
29
|
+
import os
|
|
30
|
+
import importlib
|
|
31
|
+
import importlib.util
|
|
32
|
+
import time
|
|
33
|
+
from datetime import datetime, timezone
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger("velocity.db.migrations")
|
|
38
|
+
|
|
39
|
+
# ── Global migration registry ──────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
_registry: Dict[int, Dict[str, Any]] = {}
|
|
42
|
+
|
|
43
|
+
TRACKING_TABLE = "velocity_migrations"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def migration(version: int, description: str = "", down: bool = False):
|
|
47
|
+
"""
|
|
48
|
+
Decorator that registers a function as a migration step.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
version: Integer migration version (must be unique per direction).
|
|
52
|
+
description: Human-readable description of what this migration does.
|
|
53
|
+
down: If True, registers as the rollback (down) function for this version.
|
|
54
|
+
|
|
55
|
+
Example::
|
|
56
|
+
|
|
57
|
+
@migration(version=1, description="Add users table")
|
|
58
|
+
def migrate_001(tx):
|
|
59
|
+
tx.table("users").create({"name": "TEXT", "email": "TEXT"})
|
|
60
|
+
|
|
61
|
+
@migration(version=1, down=True)
|
|
62
|
+
def rollback_001(tx):
|
|
63
|
+
tx.table("users").drop()
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def decorator(func: Callable) -> Callable:
|
|
67
|
+
if version not in _registry:
|
|
68
|
+
_registry[version] = {"up": None, "down": None, "description": ""}
|
|
69
|
+
|
|
70
|
+
direction = "down" if down else "up"
|
|
71
|
+
if _registry[version][direction] is not None:
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"Migration version {version} already has a '{direction}' function registered: "
|
|
74
|
+
f"{_registry[version][direction].__name__}"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
_registry[version][direction] = func
|
|
78
|
+
if description and not down:
|
|
79
|
+
_registry[version]["description"] = description
|
|
80
|
+
elif description and down and not _registry[version]["description"]:
|
|
81
|
+
_registry[version]["description"] = description
|
|
82
|
+
|
|
83
|
+
return func
|
|
84
|
+
|
|
85
|
+
return decorator
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def clear_registry():
|
|
89
|
+
"""Clear all registered migrations. Useful for testing."""
|
|
90
|
+
_registry.clear()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_registry() -> Dict[int, Dict[str, Any]]:
|
|
94
|
+
"""Return a copy of the current migration registry."""
|
|
95
|
+
return dict(_registry)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ── Migration Runner ────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class MigrationRunner:
|
|
102
|
+
"""
|
|
103
|
+
Discovers, applies, and rolls back schema migrations.
|
|
104
|
+
|
|
105
|
+
Migrations are tracked in a ``velocity_migrations`` table with columns:
|
|
106
|
+
|
|
107
|
+
- ``version`` (INT, PRIMARY KEY) — migration version number
|
|
108
|
+
- ``description`` (TEXT) — human-readable description
|
|
109
|
+
- ``applied_at`` (TIMESTAMPTZ) — when the migration was applied
|
|
110
|
+
- ``checksum`` (TEXT) — SHA-256 of the migration function source
|
|
111
|
+
- ``execution_ms`` (INT) — how long the migration took in milliseconds
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
engine: A velocity Engine instance.
|
|
115
|
+
migrations_dir: Optional path to a directory containing migration .py files.
|
|
116
|
+
If provided, all .py files in that directory are imported to register
|
|
117
|
+
their ``@migration`` decorated functions.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def __init__(self, engine, migrations_dir: Optional[str] = None):
|
|
121
|
+
self.engine = engine
|
|
122
|
+
self._migrations_dir = migrations_dir
|
|
123
|
+
|
|
124
|
+
if migrations_dir:
|
|
125
|
+
self._load_migrations_from_dir(migrations_dir)
|
|
126
|
+
|
|
127
|
+
# ── Discovery ───────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
def _load_migrations_from_dir(self, dir_path: str) -> None:
|
|
130
|
+
"""Import all .py files from a directory to register @migration decorators."""
|
|
131
|
+
p = Path(dir_path)
|
|
132
|
+
if not p.is_dir():
|
|
133
|
+
raise FileNotFoundError(f"Migrations directory not found: {dir_path}")
|
|
134
|
+
|
|
135
|
+
for py_file in sorted(p.glob("*.py")):
|
|
136
|
+
if py_file.name.startswith("_"):
|
|
137
|
+
continue
|
|
138
|
+
module_name = f"velocity_migrations.{py_file.stem}"
|
|
139
|
+
spec = importlib.util.spec_from_file_location(module_name, py_file)
|
|
140
|
+
if spec and spec.loader:
|
|
141
|
+
mod = importlib.util.module_from_spec(spec)
|
|
142
|
+
spec.loader.exec_module(mod)
|
|
143
|
+
|
|
144
|
+
# ── Tracking table ──────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
def _ensure_tracking_table(self, tx) -> None:
|
|
147
|
+
"""Create the velocity_migrations tracking table if it doesn't exist."""
|
|
148
|
+
table = tx.table(TRACKING_TABLE)
|
|
149
|
+
if not table.exists():
|
|
150
|
+
# Use raw SQL to avoid velocity system columns — we manage our own schema
|
|
151
|
+
tx.execute(
|
|
152
|
+
f"""
|
|
153
|
+
CREATE TABLE IF NOT EXISTS {TRACKING_TABLE} (
|
|
154
|
+
version INTEGER PRIMARY KEY,
|
|
155
|
+
description TEXT NOT NULL DEFAULT '',
|
|
156
|
+
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
157
|
+
checksum TEXT NOT NULL DEFAULT '',
|
|
158
|
+
execution_ms INTEGER NOT NULL DEFAULT 0
|
|
159
|
+
)
|
|
160
|
+
""",
|
|
161
|
+
cursor=tx.cursor(),
|
|
162
|
+
)
|
|
163
|
+
logger.info("Created tracking table: %s", TRACKING_TABLE)
|
|
164
|
+
|
|
165
|
+
def _get_applied_versions(self, tx) -> Dict[int, Dict[str, Any]]:
|
|
166
|
+
"""Return dict of {version: {description, applied_at, checksum, execution_ms}}."""
|
|
167
|
+
self._ensure_tracking_table(tx)
|
|
168
|
+
result = tx.execute(
|
|
169
|
+
f"SELECT version, description, applied_at, checksum, execution_ms "
|
|
170
|
+
f"FROM {TRACKING_TABLE} ORDER BY version",
|
|
171
|
+
cursor=tx.cursor(),
|
|
172
|
+
)
|
|
173
|
+
applied = {}
|
|
174
|
+
for row in result.all():
|
|
175
|
+
applied[row["version"]] = {
|
|
176
|
+
"description": row["description"],
|
|
177
|
+
"applied_at": row["applied_at"],
|
|
178
|
+
"checksum": row["checksum"],
|
|
179
|
+
"execution_ms": row["execution_ms"],
|
|
180
|
+
}
|
|
181
|
+
return applied
|
|
182
|
+
|
|
183
|
+
def _record_migration(self, tx, version: int, description: str, checksum: str, execution_ms: int) -> None:
|
|
184
|
+
"""Insert a record into the tracking table."""
|
|
185
|
+
tx.execute(
|
|
186
|
+
f"INSERT INTO {TRACKING_TABLE} (version, description, checksum, execution_ms) "
|
|
187
|
+
f"VALUES (%s, %s, %s, %s)",
|
|
188
|
+
(version, description, checksum, execution_ms),
|
|
189
|
+
cursor=tx.cursor(),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def _remove_migration_record(self, tx, version: int) -> None:
|
|
193
|
+
"""Delete a record from the tracking table."""
|
|
194
|
+
tx.execute(
|
|
195
|
+
f"DELETE FROM {TRACKING_TABLE} WHERE version = %s",
|
|
196
|
+
(version,),
|
|
197
|
+
cursor=tx.cursor(),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# ── Checksum ────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
@staticmethod
|
|
203
|
+
def _checksum(func: Callable) -> str:
|
|
204
|
+
"""SHA-256 hash of a migration function's source code."""
|
|
205
|
+
try:
|
|
206
|
+
source = inspect.getsource(func)
|
|
207
|
+
except (OSError, TypeError):
|
|
208
|
+
source = func.__name__
|
|
209
|
+
return hashlib.sha256(source.encode()).hexdigest()[:16]
|
|
210
|
+
|
|
211
|
+
# ── Core operations ─────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
def pending(self) -> List[int]:
|
|
214
|
+
"""
|
|
215
|
+
Return list of pending (unapplied) migration versions, sorted ascending.
|
|
216
|
+
"""
|
|
217
|
+
tx = self.engine.transaction()
|
|
218
|
+
try:
|
|
219
|
+
applied = self._get_applied_versions(tx)
|
|
220
|
+
pending = sorted(v for v in _registry if v not in applied and _registry[v]["up"] is not None)
|
|
221
|
+
return pending
|
|
222
|
+
finally:
|
|
223
|
+
tx.close()
|
|
224
|
+
|
|
225
|
+
def status(self) -> List[Dict[str, Any]]:
|
|
226
|
+
"""
|
|
227
|
+
Return migration status for all registered versions.
|
|
228
|
+
|
|
229
|
+
Returns a list of dicts with keys:
|
|
230
|
+
- version, description, status ("applied" | "pending"), applied_at, checksum_match
|
|
231
|
+
"""
|
|
232
|
+
tx = self.engine.transaction()
|
|
233
|
+
try:
|
|
234
|
+
applied = self._get_applied_versions(tx)
|
|
235
|
+
result = []
|
|
236
|
+
all_versions = sorted(set(list(_registry.keys()) + list(applied.keys())))
|
|
237
|
+
|
|
238
|
+
for v in all_versions:
|
|
239
|
+
entry: Dict[str, Any] = {"version": v}
|
|
240
|
+
reg = _registry.get(v, {})
|
|
241
|
+
app = applied.get(v)
|
|
242
|
+
|
|
243
|
+
entry["description"] = reg.get("description", "") or (app["description"] if app else "")
|
|
244
|
+
|
|
245
|
+
if app:
|
|
246
|
+
entry["status"] = "applied"
|
|
247
|
+
entry["applied_at"] = app["applied_at"]
|
|
248
|
+
entry["execution_ms"] = app["execution_ms"]
|
|
249
|
+
# Check if source has changed since applied
|
|
250
|
+
up_func = reg.get("up")
|
|
251
|
+
if up_func:
|
|
252
|
+
entry["checksum_match"] = self._checksum(up_func) == app["checksum"]
|
|
253
|
+
else:
|
|
254
|
+
entry["checksum_match"] = None # Not in registry
|
|
255
|
+
else:
|
|
256
|
+
entry["status"] = "pending"
|
|
257
|
+
entry["applied_at"] = None
|
|
258
|
+
entry["execution_ms"] = None
|
|
259
|
+
entry["checksum_match"] = None
|
|
260
|
+
|
|
261
|
+
has_down = reg.get("down") is not None if reg else False
|
|
262
|
+
entry["reversible"] = has_down
|
|
263
|
+
|
|
264
|
+
result.append(entry)
|
|
265
|
+
|
|
266
|
+
return result
|
|
267
|
+
finally:
|
|
268
|
+
tx.close()
|
|
269
|
+
|
|
270
|
+
def migrate(self, target: Optional[int] = None, dry_run: bool = False) -> List[int]:
|
|
271
|
+
"""
|
|
272
|
+
Apply pending migrations up to ``target`` version (inclusive).
|
|
273
|
+
|
|
274
|
+
Each migration runs in its own transaction — if a migration fails,
|
|
275
|
+
all previously applied migrations in this run remain committed.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
target: Apply up to this version. None = apply all pending.
|
|
279
|
+
dry_run: If True, log what would be done without applying.
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
List of versions that were applied.
|
|
283
|
+
"""
|
|
284
|
+
pending = self.pending()
|
|
285
|
+
if target is not None:
|
|
286
|
+
pending = [v for v in pending if v <= target]
|
|
287
|
+
|
|
288
|
+
if not pending:
|
|
289
|
+
logger.info("No pending migrations.")
|
|
290
|
+
return []
|
|
291
|
+
|
|
292
|
+
if dry_run:
|
|
293
|
+
for v in pending:
|
|
294
|
+
desc = _registry[v].get("description", "")
|
|
295
|
+
logger.info("[DRY RUN] Would apply migration %d: %s", v, desc)
|
|
296
|
+
return pending
|
|
297
|
+
|
|
298
|
+
applied = []
|
|
299
|
+
for v in pending:
|
|
300
|
+
reg = _registry[v]
|
|
301
|
+
up_func = reg["up"]
|
|
302
|
+
desc = reg.get("description", "")
|
|
303
|
+
checksum = self._checksum(up_func)
|
|
304
|
+
|
|
305
|
+
logger.info("Applying migration %d: %s", v, desc)
|
|
306
|
+
start = time.perf_counter()
|
|
307
|
+
|
|
308
|
+
# Each migration gets its own transaction
|
|
309
|
+
tx = self.engine.transaction()
|
|
310
|
+
try:
|
|
311
|
+
# Temporarily unlock schema for migration DDL
|
|
312
|
+
was_locked = self.engine.schema_locked
|
|
313
|
+
if was_locked:
|
|
314
|
+
self.engine.unlock_schema()
|
|
315
|
+
try:
|
|
316
|
+
self._ensure_tracking_table(tx)
|
|
317
|
+
up_func(tx)
|
|
318
|
+
elapsed_ms = int((time.perf_counter() - start) * 1000)
|
|
319
|
+
self._record_migration(tx, v, desc, checksum, elapsed_ms)
|
|
320
|
+
tx.commit()
|
|
321
|
+
applied.append(v)
|
|
322
|
+
logger.info("Applied migration %d in %dms", v, elapsed_ms)
|
|
323
|
+
finally:
|
|
324
|
+
if was_locked:
|
|
325
|
+
self.engine.lock_schema()
|
|
326
|
+
except Exception:
|
|
327
|
+
tx.rollback()
|
|
328
|
+
logger.error("Migration %d failed — rolled back", v, exc_info=True)
|
|
329
|
+
raise
|
|
330
|
+
finally:
|
|
331
|
+
tx.close()
|
|
332
|
+
|
|
333
|
+
return applied
|
|
334
|
+
|
|
335
|
+
def rollback(self, target: Optional[int] = None, dry_run: bool = False) -> List[int]:
|
|
336
|
+
"""
|
|
337
|
+
Roll back applied migrations down to ``target`` version (exclusive).
|
|
338
|
+
|
|
339
|
+
Migrations are rolled back in reverse order. Each rollback runs in its
|
|
340
|
+
own transaction.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
target: Roll back to this version (this version stays applied).
|
|
344
|
+
None = roll back the most recent migration only.
|
|
345
|
+
dry_run: If True, log what would be done without applying.
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
List of versions that were rolled back (in rollback order).
|
|
349
|
+
|
|
350
|
+
Raises:
|
|
351
|
+
ValueError: If a migration has no ``down`` function registered.
|
|
352
|
+
"""
|
|
353
|
+
tx = self.engine.transaction()
|
|
354
|
+
try:
|
|
355
|
+
applied = self._get_applied_versions(tx)
|
|
356
|
+
finally:
|
|
357
|
+
tx.close()
|
|
358
|
+
|
|
359
|
+
applied_versions = sorted(applied.keys(), reverse=True)
|
|
360
|
+
|
|
361
|
+
if not applied_versions:
|
|
362
|
+
logger.info("No migrations to roll back.")
|
|
363
|
+
return []
|
|
364
|
+
|
|
365
|
+
if target is None:
|
|
366
|
+
# Roll back only the latest
|
|
367
|
+
to_rollback = [applied_versions[0]]
|
|
368
|
+
else:
|
|
369
|
+
to_rollback = [v for v in applied_versions if v > target]
|
|
370
|
+
|
|
371
|
+
if not to_rollback:
|
|
372
|
+
logger.info("No migrations to roll back (already at or below target %d).", target)
|
|
373
|
+
return []
|
|
374
|
+
|
|
375
|
+
# Check all have down functions before starting
|
|
376
|
+
for v in to_rollback:
|
|
377
|
+
reg = _registry.get(v, {})
|
|
378
|
+
if not reg.get("down"):
|
|
379
|
+
raise ValueError(
|
|
380
|
+
f"Migration {v} has no 'down' function registered — cannot roll back."
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
if dry_run:
|
|
384
|
+
for v in to_rollback:
|
|
385
|
+
desc = _registry.get(v, {}).get("description", "")
|
|
386
|
+
logger.info("[DRY RUN] Would roll back migration %d: %s", v, desc)
|
|
387
|
+
return to_rollback
|
|
388
|
+
|
|
389
|
+
rolled_back = []
|
|
390
|
+
for v in to_rollback:
|
|
391
|
+
reg = _registry[v]
|
|
392
|
+
down_func = reg["down"]
|
|
393
|
+
desc = reg.get("description", "")
|
|
394
|
+
|
|
395
|
+
logger.info("Rolling back migration %d: %s", v, desc)
|
|
396
|
+
start = time.perf_counter()
|
|
397
|
+
|
|
398
|
+
tx = self.engine.transaction()
|
|
399
|
+
try:
|
|
400
|
+
was_locked = self.engine.schema_locked
|
|
401
|
+
if was_locked:
|
|
402
|
+
self.engine.unlock_schema()
|
|
403
|
+
try:
|
|
404
|
+
down_func(tx)
|
|
405
|
+
self._remove_migration_record(tx, v)
|
|
406
|
+
tx.commit()
|
|
407
|
+
elapsed_ms = int((time.perf_counter() - start) * 1000)
|
|
408
|
+
rolled_back.append(v)
|
|
409
|
+
logger.info("Rolled back migration %d in %dms", v, elapsed_ms)
|
|
410
|
+
finally:
|
|
411
|
+
if was_locked:
|
|
412
|
+
self.engine.lock_schema()
|
|
413
|
+
except Exception:
|
|
414
|
+
tx.rollback()
|
|
415
|
+
logger.error("Rollback of migration %d failed", v, exc_info=True)
|
|
416
|
+
raise
|
|
417
|
+
finally:
|
|
418
|
+
tx.close()
|
|
419
|
+
|
|
420
|
+
return rolled_back
|
|
421
|
+
|
|
422
|
+
def diff(self) -> Dict[str, Any]:
|
|
423
|
+
"""
|
|
424
|
+
Compare current database schema with the expected state from migrations.
|
|
425
|
+
|
|
426
|
+
Returns a dict with:
|
|
427
|
+
- ``pending_count``: Number of unapplied migrations.
|
|
428
|
+
- ``pending_versions``: List of unapplied version numbers.
|
|
429
|
+
- ``applied_count``: Number of applied migrations.
|
|
430
|
+
- ``modified``: List of versions where the source checksum differs
|
|
431
|
+
from what was applied (migration was edited after being applied).
|
|
432
|
+
- ``orphaned``: List of versions applied in DB but not in the registry
|
|
433
|
+
(migration file was deleted or not loaded).
|
|
434
|
+
"""
|
|
435
|
+
tx = self.engine.transaction()
|
|
436
|
+
try:
|
|
437
|
+
applied = self._get_applied_versions(tx)
|
|
438
|
+
finally:
|
|
439
|
+
tx.close()
|
|
440
|
+
|
|
441
|
+
pending_versions = sorted(v for v in _registry if v not in applied and _registry[v].get("up"))
|
|
442
|
+
orphaned = sorted(v for v in applied if v not in _registry)
|
|
443
|
+
modified = []
|
|
444
|
+
for v, app in applied.items():
|
|
445
|
+
reg = _registry.get(v, {})
|
|
446
|
+
up_func = reg.get("up")
|
|
447
|
+
if up_func and self._checksum(up_func) != app["checksum"]:
|
|
448
|
+
modified.append(v)
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
"pending_count": len(pending_versions),
|
|
452
|
+
"pending_versions": pending_versions,
|
|
453
|
+
"applied_count": len(applied),
|
|
454
|
+
"modified": sorted(modified),
|
|
455
|
+
"orphaned": orphaned,
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
# ── CLI ─────────────────────────────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _cli_main(args: Optional[List[str]] = None) -> int:
|
|
463
|
+
"""
|
|
464
|
+
CLI entry point for ``velocity migrate``.
|
|
465
|
+
|
|
466
|
+
Commands:
|
|
467
|
+
velocity migrate Apply all pending migrations
|
|
468
|
+
velocity migrate --to 5 Apply up to version 5
|
|
469
|
+
velocity migrate --dry-run Show what would be applied
|
|
470
|
+
velocity rollback Roll back the latest migration
|
|
471
|
+
velocity rollback --to 3 Roll back to version 3
|
|
472
|
+
velocity status Show migration status
|
|
473
|
+
velocity diff Compare DB schema with migration registry
|
|
474
|
+
"""
|
|
475
|
+
import argparse
|
|
476
|
+
|
|
477
|
+
parser = argparse.ArgumentParser(
|
|
478
|
+
prog="velocity",
|
|
479
|
+
description="Velocity-Python schema migration tool",
|
|
480
|
+
)
|
|
481
|
+
sub = parser.add_subparsers(dest="command")
|
|
482
|
+
|
|
483
|
+
# migrate
|
|
484
|
+
mig = sub.add_parser("migrate", help="Apply pending migrations")
|
|
485
|
+
mig.add_argument("--to", type=int, default=None, help="Apply up to this version (inclusive)")
|
|
486
|
+
mig.add_argument("--dry-run", action="store_true", help="Show what would be applied")
|
|
487
|
+
mig.add_argument("--dir", type=str, default=None, help="Directory containing migration .py files")
|
|
488
|
+
|
|
489
|
+
# rollback
|
|
490
|
+
rb = sub.add_parser("rollback", help="Roll back migrations")
|
|
491
|
+
rb.add_argument("--to", type=int, default=None, help="Roll back to this version (exclusive, version stays)")
|
|
492
|
+
rb.add_argument("--dry-run", action="store_true", help="Show what would be rolled back")
|
|
493
|
+
rb.add_argument("--dir", type=str, default=None, help="Directory containing migration .py files")
|
|
494
|
+
|
|
495
|
+
# status
|
|
496
|
+
st = sub.add_parser("status", help="Show migration status")
|
|
497
|
+
st.add_argument("--dir", type=str, default=None, help="Directory containing migration .py files")
|
|
498
|
+
|
|
499
|
+
# diff
|
|
500
|
+
df = sub.add_parser("diff", help="Compare DB schema with migration registry")
|
|
501
|
+
df.add_argument("--dir", type=str, default=None, help="Directory containing migration .py files")
|
|
502
|
+
|
|
503
|
+
parsed = parser.parse_args(args)
|
|
504
|
+
|
|
505
|
+
if not parsed.command:
|
|
506
|
+
parser.print_help()
|
|
507
|
+
return 1
|
|
508
|
+
|
|
509
|
+
# Configure logging for CLI
|
|
510
|
+
logging.basicConfig(
|
|
511
|
+
level=logging.INFO,
|
|
512
|
+
format="%(levelname)s %(message)s",
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
# Initialize engine from env vars (standard velocity pattern)
|
|
516
|
+
try:
|
|
517
|
+
from velocity.db.servers.postgres import initialize
|
|
518
|
+
engine = initialize()
|
|
519
|
+
except Exception as e:
|
|
520
|
+
logger.error("Failed to initialize database engine: %s", e)
|
|
521
|
+
logger.error("Set DBHost, DBDatabase, DBUser, DBPassword environment variables.")
|
|
522
|
+
return 1
|
|
523
|
+
|
|
524
|
+
migrations_dir = getattr(parsed, "dir", None)
|
|
525
|
+
runner = MigrationRunner(engine, migrations_dir=migrations_dir)
|
|
526
|
+
|
|
527
|
+
try:
|
|
528
|
+
if parsed.command == "migrate":
|
|
529
|
+
applied = runner.migrate(target=parsed.to, dry_run=parsed.dry_run)
|
|
530
|
+
if not parsed.dry_run:
|
|
531
|
+
print(f"{len(applied)} migration(s) applied.")
|
|
532
|
+
return 0
|
|
533
|
+
|
|
534
|
+
elif parsed.command == "rollback":
|
|
535
|
+
rolled = runner.rollback(target=parsed.to, dry_run=parsed.dry_run)
|
|
536
|
+
if not parsed.dry_run:
|
|
537
|
+
print(f"{len(rolled)} migration(s) rolled back.")
|
|
538
|
+
return 0
|
|
539
|
+
|
|
540
|
+
elif parsed.command == "status":
|
|
541
|
+
entries = runner.status()
|
|
542
|
+
if not entries:
|
|
543
|
+
print("No migrations registered or applied.")
|
|
544
|
+
return 0
|
|
545
|
+
print(f"{'Ver':>4} {'Status':<9} {'Rev':>3} {'Time':>7} Description")
|
|
546
|
+
print("-" * 60)
|
|
547
|
+
for e in entries:
|
|
548
|
+
status_str = e["status"]
|
|
549
|
+
if e.get("checksum_match") is False:
|
|
550
|
+
status_str = "MODIFIED"
|
|
551
|
+
rev = "yes" if e.get("reversible") else "no"
|
|
552
|
+
ms = f"{e['execution_ms']}ms" if e.get("execution_ms") is not None else "-"
|
|
553
|
+
print(f"{e['version']:>4} {status_str:<9} {rev:>3} {ms:>7} {e['description']}")
|
|
554
|
+
return 0
|
|
555
|
+
|
|
556
|
+
elif parsed.command == "diff":
|
|
557
|
+
d = runner.diff()
|
|
558
|
+
print(f"Applied: {d['applied_count']}")
|
|
559
|
+
print(f"Pending: {d['pending_count']} {d['pending_versions'] or ''}")
|
|
560
|
+
if d["modified"]:
|
|
561
|
+
print(f"Modified since applied: {d['modified']}")
|
|
562
|
+
if d["orphaned"]:
|
|
563
|
+
print(f"Orphaned (in DB, not in code): {d['orphaned']}")
|
|
564
|
+
if not d["pending_versions"] and not d["modified"] and not d["orphaned"]:
|
|
565
|
+
print("Schema is up to date.")
|
|
566
|
+
return 0
|
|
567
|
+
|
|
568
|
+
except Exception as e:
|
|
569
|
+
logger.error("%s", e)
|
|
570
|
+
return 1
|
|
571
|
+
|
|
572
|
+
return 0
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def cli():
|
|
576
|
+
"""Setuptools console_scripts entry point."""
|
|
577
|
+
import sys
|
|
578
|
+
|
|
579
|
+
sys.exit(_cli_main())
|
|
@@ -37,6 +37,7 @@ src/velocity/aws/tests/test_lambda_handler_json_serialization.py
|
|
|
37
37
|
src/velocity/aws/tests/test_response.py
|
|
38
38
|
src/velocity/db/__init__.py
|
|
39
39
|
src/velocity/db/exceptions.py
|
|
40
|
+
src/velocity/db/migrations.py
|
|
40
41
|
src/velocity/db/utils.py
|
|
41
42
|
src/velocity/db/core/__init__.py
|
|
42
43
|
src/velocity/db/core/async_support.py
|
|
@@ -146,6 +147,7 @@ src/velocity/payment/stripe_adapter.py
|
|
|
146
147
|
src/velocity_python.egg-info/PKG-INFO
|
|
147
148
|
src/velocity_python.egg-info/SOURCES.txt
|
|
148
149
|
src/velocity_python.egg-info/dependency_links.txt
|
|
150
|
+
src/velocity_python.egg-info/entry_points.txt
|
|
149
151
|
src/velocity_python.egg-info/requires.txt
|
|
150
152
|
src/velocity_python.egg-info/top_level.txt
|
|
151
153
|
tests/test_amplify_build.py
|
|
@@ -173,6 +175,7 @@ tests/test_psycopg3_upgrade.py
|
|
|
173
175
|
tests/test_query_cache.py
|
|
174
176
|
tests/test_row_batch_update.py
|
|
175
177
|
tests/test_row_cache_staleness.py
|
|
178
|
+
tests/test_schema_migrations.py
|
|
176
179
|
tests/test_security_hardening.py
|
|
177
180
|
tests/test_sqs_per_record_transactions.py
|
|
178
181
|
tests/test_sys_modified_count_postgres_demo.py
|