quantum-framework 0.9.0__py3-none-any.whl
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.
- quantum/__init__.py +1 -0
- quantum/cli/__init__.py +2 -0
- quantum/cli/commands/__init__.py +15 -0
- quantum/cli/commands/build.py +417 -0
- quantum/cli/commands/dev.py +185 -0
- quantum/cli/commands/docs.py +329 -0
- quantum/cli/commands/lint.py +523 -0
- quantum/cli/commands/migrate.py +527 -0
- quantum/cli/commands/new.py +622 -0
- quantum/cli/commands/serve.py +190 -0
- quantum/cli/commands/test.py +193 -0
- quantum/cli/deploy.py +810 -0
- quantum/cli/hot_reload.py +951 -0
- quantum/cli/jobs.py +356 -0
- quantum/cli/mq.py +582 -0
- quantum/cli/pkg.py +390 -0
- quantum/cli/runner.py +547 -0
- quantum/cli/server_process.py +159 -0
- quantum/cli/utils.py +334 -0
- quantum/compiler/__init__.py +30 -0
- quantum/compiler/base_generator.py +367 -0
- quantum/compiler/cli.py +295 -0
- quantum/compiler/expression_transformer.py +444 -0
- quantum/compiler/javascript/__init__.py +10 -0
- quantum/compiler/javascript/generator.py +659 -0
- quantum/compiler/optimizer.py +270 -0
- quantum/compiler/python/__init__.py +10 -0
- quantum/compiler/python/generator.py +883 -0
- quantum/compiler/python/runtime.py +863 -0
- quantum/compiler/transpiler.py +330 -0
- quantum/core/__init__.py +3 -0
- quantum/core/ast_nodes.py +2611 -0
- quantum/core/expression_diagnostics.py +87 -0
- quantum/core/expression_stdlib.py +148 -0
- quantum/core/expressions.py +591 -0
- quantum/core/features/agents/src/__init__.py +30 -0
- quantum/core/features/agents/src/ast_node.py +540 -0
- quantum/core/features/conditionals/src/__init__.py +8 -0
- quantum/core/features/conditionals/src/ast_node.py +68 -0
- quantum/core/features/data_fetching/src/__init__.py +21 -0
- quantum/core/features/data_fetching/src/ast_node.py +312 -0
- quantum/core/features/data_fetching/src/desktop_adapter.py +351 -0
- quantum/core/features/data_fetching/src/html_adapter.py +474 -0
- quantum/core/features/data_fetching/src/parser.py +225 -0
- quantum/core/features/data_import/src/__init__.py +0 -0
- quantum/core/features/data_import/src/ast_node.py +291 -0
- quantum/core/features/data_import/src/runtime.py +538 -0
- quantum/core/features/dump/src/__init__.py +12 -0
- quantum/core/features/dump/src/ast_node.py +106 -0
- quantum/core/features/dump/src/parser.py +61 -0
- quantum/core/features/dump/src/runtime.py +246 -0
- quantum/core/features/functions/src/__init__.py +8 -0
- quantum/core/features/functions/src/ast_node.py +149 -0
- quantum/core/features/game_engine_2d/src/__init__.py +19 -0
- quantum/core/features/game_engine_2d/src/ast_nodes.py +1719 -0
- quantum/core/features/game_engine_2d/src/parser.py +983 -0
- quantum/core/features/invocation/src/__init__.py +0 -0
- quantum/core/features/invocation/src/ast_node.py +146 -0
- quantum/core/features/invocation/src/runtime.py +327 -0
- quantum/core/features/knowledge_base/src/__init__.py +6 -0
- quantum/core/features/knowledge_base/src/ast_node.py +113 -0
- quantum/core/features/knowledge_base/src/parser.py +82 -0
- quantum/core/features/logging/src/__init__.py +12 -0
- quantum/core/features/logging/src/ast_node.py +111 -0
- quantum/core/features/logging/src/parser.py +50 -0
- quantum/core/features/logging/src/runtime.py +190 -0
- quantum/core/features/loops/src/__init__.py +8 -0
- quantum/core/features/loops/src/ast_node.py +60 -0
- quantum/core/features/query/src/__init__.py +0 -0
- quantum/core/features/query/src/database_service.py +322 -0
- quantum/core/features/query/src/query_validators.py +20 -0
- quantum/core/features/state_management/src/__init__.py +11 -0
- quantum/core/features/state_management/src/ast_node.py +228 -0
- quantum/core/features/terminal_engine/src/__init__.py +21 -0
- quantum/core/features/terminal_engine/src/ast_nodes.py +560 -0
- quantum/core/features/terminal_engine/src/parser.py +361 -0
- quantum/core/features/testing_engine/src/__init__.py +41 -0
- quantum/core/features/testing_engine/src/ast_nodes.py +1212 -0
- quantum/core/features/testing_engine/src/parser.py +604 -0
- quantum/core/features/theming/src/__init__.py +48 -0
- quantum/core/features/theming/src/ast_node.py +137 -0
- quantum/core/features/theming/src/presets.py +405 -0
- quantum/core/features/ui_engine/src/__init__.py +20 -0
- quantum/core/features/ui_engine/src/ast_nodes.py +1854 -0
- quantum/core/features/ui_engine/src/parser.py +1106 -0
- quantum/core/features/websocket/src/__init__.py +24 -0
- quantum/core/features/websocket/src/ast_node.py +247 -0
- quantum/core/html_compat.py +299 -0
- quantum/core/parser.py +1235 -0
- quantum/core/parser_registry.py +213 -0
- quantum/core/parsers/__init__.py +76 -0
- quantum/core/parsers/ai/__init__.py +12 -0
- quantum/core/parsers/ai/agent_parser.py +106 -0
- quantum/core/parsers/ai/knowledge_parser.py +86 -0
- quantum/core/parsers/ai/llm_parser.py +78 -0
- quantum/core/parsers/ai/team_parser.py +88 -0
- quantum/core/parsers/base.py +322 -0
- quantum/core/parsers/composition/__init__.py +10 -0
- quantum/core/parsers/composition/import_parser.py +50 -0
- quantum/core/parsers/composition/slot_parser.py +48 -0
- quantum/core/parsers/control_flow/__init__.py +11 -0
- quantum/core/parsers/control_flow/if_parser.py +68 -0
- quantum/core/parsers/control_flow/loop_parser.py +105 -0
- quantum/core/parsers/control_flow/set_parser.py +89 -0
- quantum/core/parsers/data/__init__.py +12 -0
- quantum/core/parsers/data/data_parser.py +217 -0
- quantum/core/parsers/data/invoke_parser.py +116 -0
- quantum/core/parsers/data/query_parser.py +188 -0
- quantum/core/parsers/data/transaction_parser.py +77 -0
- quantum/core/parsers/events/__init__.py +9 -0
- quantum/core/parsers/events/dispatch_event_parser.py +44 -0
- quantum/core/parsers/forms/__init__.py +11 -0
- quantum/core/parsers/forms/action_parser.py +54 -0
- quantum/core/parsers/forms/flash_parser.py +34 -0
- quantum/core/parsers/forms/redirect_parser.py +33 -0
- quantum/core/parsers/functions/__init__.py +11 -0
- quantum/core/parsers/functions/function_parser.py +137 -0
- quantum/core/parsers/functions/param_parser.py +66 -0
- quantum/core/parsers/functions/return_parser.py +31 -0
- quantum/core/parsers/html/__init__.py +10 -0
- quantum/core/parsers/html/component_call_parser.py +130 -0
- quantum/core/parsers/html/html_parser.py +115 -0
- quantum/core/parsers/jobs/__init__.py +11 -0
- quantum/core/parsers/jobs/job_parser.py +71 -0
- quantum/core/parsers/jobs/schedule_parser.py +62 -0
- quantum/core/parsers/jobs/thread_parser.py +57 -0
- quantum/core/parsers/messaging/__init__.py +17 -0
- quantum/core/parsers/messaging/message_ack_parser.py +30 -0
- quantum/core/parsers/messaging/message_nack_parser.py +30 -0
- quantum/core/parsers/messaging/message_parser.py +114 -0
- quantum/core/parsers/messaging/queue_parser.py +61 -0
- quantum/core/parsers/messaging/websocket_parser.py +121 -0
- quantum/core/parsers/persistence/__init__.py +9 -0
- quantum/core/parsers/persistence/persist_parser.py +64 -0
- quantum/core/parsers/routing/__init__.py +9 -0
- quantum/core/parsers/routing/route_parser.py +41 -0
- quantum/core/parsers/scripting/__init__.py +12 -0
- quantum/core/parsers/scripting/pyclass_parser.py +62 -0
- quantum/core/parsers/scripting/pydecorator_parser.py +68 -0
- quantum/core/parsers/scripting/pyimport_parser.py +52 -0
- quantum/core/parsers/scripting/python_parser.py +49 -0
- quantum/core/parsers/services/__init__.py +12 -0
- quantum/core/parsers/services/dump_parser.py +52 -0
- quantum/core/parsers/services/file_parser.py +48 -0
- quantum/core/parsers/services/log_parser.py +46 -0
- quantum/core/parsers/services/mail_parser.py +65 -0
- quantum/core/tiers.py +82 -0
- quantum/packages/__init__.py +28 -0
- quantum/packages/manager.py +413 -0
- quantum/packages/manifest.py +351 -0
- quantum/packages/registry.py +399 -0
- quantum/packages/resolver.py +336 -0
- quantum/plugins/__init__.py +33 -0
- quantum/plugins/hooks.py +329 -0
- quantum/plugins/loader.py +479 -0
- quantum/plugins/manifest.py +336 -0
- quantum/plugins/registry.py +371 -0
- quantum/runtime/__init__.py +28 -0
- quantum/runtime/action_handler.py +443 -0
- quantum/runtime/adapters/__init__.py +88 -0
- quantum/runtime/adapters/memory_adapter.py +690 -0
- quantum/runtime/adapters/rabbitmq_adapter.py +715 -0
- quantum/runtime/adapters/redis_adapter.py +582 -0
- quantum/runtime/adapters/sqlite_adapter.py +414 -0
- quantum/runtime/agent_service.py +1133 -0
- quantum/runtime/api_server.py +86 -0
- quantum/runtime/ast_cache.py +506 -0
- quantum/runtime/auth_service.py +267 -0
- quantum/runtime/component.py +990 -0
- quantum/runtime/component_composer.py +319 -0
- quantum/runtime/component_resolver.py +174 -0
- quantum/runtime/database_service.py +598 -0
- quantum/runtime/email_service.py +162 -0
- quantum/runtime/error_handler.py +295 -0
- quantum/runtime/execution_context.py +286 -0
- quantum/runtime/executor_registry.py +171 -0
- quantum/runtime/executors/__init__.py +71 -0
- quantum/runtime/executors/ai/__init__.py +12 -0
- quantum/runtime/executors/ai/agent_executor.py +217 -0
- quantum/runtime/executors/ai/knowledge_executor.py +114 -0
- quantum/runtime/executors/ai/llm_executor.py +153 -0
- quantum/runtime/executors/ai/team_executor.py +171 -0
- quantum/runtime/executors/base.py +262 -0
- quantum/runtime/executors/control_flow/__init__.py +11 -0
- quantum/runtime/executors/control_flow/if_executor.py +93 -0
- quantum/runtime/executors/control_flow/loop_executor.py +307 -0
- quantum/runtime/executors/control_flow/set_executor.py +412 -0
- quantum/runtime/executors/data/__init__.py +12 -0
- quantum/runtime/executors/data/data_executor.py +145 -0
- quantum/runtime/executors/data/invoke_executor.py +176 -0
- quantum/runtime/executors/data/query_executor.py +256 -0
- quantum/runtime/executors/data/transaction_executor.py +91 -0
- quantum/runtime/executors/jobs/__init__.py +11 -0
- quantum/runtime/executors/jobs/job_executor.py +190 -0
- quantum/runtime/executors/jobs/schedule_executor.py +132 -0
- quantum/runtime/executors/jobs/thread_executor.py +127 -0
- quantum/runtime/executors/messaging/__init__.py +17 -0
- quantum/runtime/executors/messaging/message_ack_executor.py +51 -0
- quantum/runtime/executors/messaging/message_executor.py +174 -0
- quantum/runtime/executors/messaging/queue_executor.py +103 -0
- quantum/runtime/executors/messaging/websocket_executor.py +197 -0
- quantum/runtime/executors/scripting/__init__.py +11 -0
- quantum/runtime/executors/scripting/pyclass_executor.py +90 -0
- quantum/runtime/executors/scripting/pyimport_executor.py +81 -0
- quantum/runtime/executors/scripting/python_executor.py +249 -0
- quantum/runtime/executors/services/__init__.py +12 -0
- quantum/runtime/executors/services/dump_executor.py +72 -0
- quantum/runtime/executors/services/file_executor.py +89 -0
- quantum/runtime/executors/services/log_executor.py +77 -0
- quantum/runtime/executors/services/mail_executor.py +81 -0
- quantum/runtime/expression_cache.py +498 -0
- quantum/runtime/file_upload_service.py +326 -0
- quantum/runtime/function_registry.py +118 -0
- quantum/runtime/game_builder.py +166 -0
- quantum/runtime/game_code_generator.py +2371 -0
- quantum/runtime/game_templates.py +2006 -0
- quantum/runtime/godot_code_generator.py +4681 -0
- quantum/runtime/godot_templates.py +1449 -0
- quantum/runtime/job_executor.py +1599 -0
- quantum/runtime/knowledge_service.py +500 -0
- quantum/runtime/llm_cache.py +100 -0
- quantum/runtime/llm_providers.py +704 -0
- quantum/runtime/llm_service.py +287 -0
- quantum/runtime/logging_setup.py +140 -0
- quantum/runtime/message_broker.py +364 -0
- quantum/runtime/message_queue_service.py +571 -0
- quantum/runtime/param_validation.py +184 -0
- quantum/runtime/pypy_compat.py +315 -0
- quantum/runtime/python_bridge.py +698 -0
- quantum/runtime/query_validators.py +304 -0
- quantum/runtime/renderer.py +733 -0
- quantum/runtime/service_container.py +444 -0
- quantum/runtime/terminal_builder.py +76 -0
- quantum/runtime/terminal_code_generator.py +607 -0
- quantum/runtime/terminal_templates.py +243 -0
- quantum/runtime/testing_builder.py +77 -0
- quantum/runtime/testing_code_generator.py +833 -0
- quantum/runtime/testing_templates.py +85 -0
- quantum/runtime/ui_builder.py +188 -0
- quantum/runtime/ui_desktop_adapter.py +1730 -0
- quantum/runtime/ui_desktop_templates.py +307 -0
- quantum/runtime/ui_html_adapter.py +2691 -0
- quantum/runtime/ui_html_templates.py +2297 -0
- quantum/runtime/ui_mobile_adapter.py +1832 -0
- quantum/runtime/ui_mobile_templates.py +1003 -0
- quantum/runtime/ui_textual_adapter.py +1866 -0
- quantum/runtime/ui_textual_templates.py +45 -0
- quantum/runtime/ui_tokens.py +465 -0
- quantum/runtime/ui_validator.py +365 -0
- quantum/runtime/validators.py +256 -0
- quantum/runtime/web_server.py +1766 -0
- quantum/runtime/websocket_adapter.py +501 -0
- quantum/runtime/websocket_service.py +585 -0
- quantum/runtime/websocket_transport.py +289 -0
- quantum/runtime/wsgi.py +101 -0
- quantum/utils/__init__.py +1 -0
- quantum_framework-0.9.0.dist-info/METADATA +244 -0
- quantum_framework-0.9.0.dist-info/RECORD +262 -0
- quantum_framework-0.9.0.dist-info/WHEEL +5 -0
- quantum_framework-0.9.0.dist-info/entry_points.txt +2 -0
- quantum_framework-0.9.0.dist-info/licenses/LICENSE +21 -0
- quantum_framework-0.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Quantum CLI - Database Migration Commands
|
|
3
|
+
Handles database migrations with automatic PostgreSQL/SQLite failover
|
|
4
|
+
"""
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import hashlib
|
|
8
|
+
import logging
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Optional, List, Dict, Any
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MigrationError(Exception):
|
|
19
|
+
"""Migration operation failed"""
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Migration:
|
|
24
|
+
"""Represents a single migration file"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, version: str, name: str, path: Path):
|
|
27
|
+
self.version = version
|
|
28
|
+
self.name = name
|
|
29
|
+
self.path = path
|
|
30
|
+
self._content = None
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def content(self) -> str:
|
|
34
|
+
if self._content is None:
|
|
35
|
+
self._content = self.path.read_text()
|
|
36
|
+
return self._content
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def checksum(self) -> str:
|
|
40
|
+
return hashlib.md5(self.content.encode()).hexdigest()
|
|
41
|
+
|
|
42
|
+
def __repr__(self):
|
|
43
|
+
return f"<Migration {self.version}: {self.name}>"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class DatabaseConnection:
|
|
47
|
+
"""Database connection with automatic failover"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, project_path: Path = None):
|
|
50
|
+
self.project_path = project_path or Path.cwd()
|
|
51
|
+
self._conn = None
|
|
52
|
+
self._db_type = None
|
|
53
|
+
|
|
54
|
+
def connect(self) -> Any:
|
|
55
|
+
"""
|
|
56
|
+
Connect to database with automatic failover.
|
|
57
|
+
Tries PostgreSQL first, falls back to SQLite.
|
|
58
|
+
"""
|
|
59
|
+
# Try to load database config from quantum.config.yaml
|
|
60
|
+
config = self._load_config()
|
|
61
|
+
|
|
62
|
+
# Try PostgreSQL
|
|
63
|
+
if self._try_postgres(config):
|
|
64
|
+
return self._conn
|
|
65
|
+
|
|
66
|
+
# Fallback to SQLite
|
|
67
|
+
logger.warning("PostgreSQL unavailable, falling back to SQLite")
|
|
68
|
+
if self._try_sqlite(config):
|
|
69
|
+
return self._conn
|
|
70
|
+
|
|
71
|
+
raise MigrationError("Could not connect to any database")
|
|
72
|
+
|
|
73
|
+
def _load_config(self) -> Dict:
|
|
74
|
+
"""Load database configuration from quantum.config.yaml"""
|
|
75
|
+
config_path = self.project_path / "quantum.config.yaml"
|
|
76
|
+
if not config_path.exists():
|
|
77
|
+
return {}
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
import yaml
|
|
81
|
+
with open(config_path) as f:
|
|
82
|
+
config = yaml.safe_load(f) or {}
|
|
83
|
+
return config.get('database', {})
|
|
84
|
+
except Exception as e:
|
|
85
|
+
logger.warning(f"Could not load config: {e}")
|
|
86
|
+
return {}
|
|
87
|
+
|
|
88
|
+
def _try_postgres(self, config: Dict) -> bool:
|
|
89
|
+
"""Try to connect to PostgreSQL"""
|
|
90
|
+
try:
|
|
91
|
+
import psycopg2
|
|
92
|
+
|
|
93
|
+
# Get connection params from config or environment
|
|
94
|
+
host = config.get('host') or os.environ.get('QUANTUM_DB_HOST', 'localhost')
|
|
95
|
+
port = config.get('port') or os.environ.get('QUANTUM_DB_PORT', '5432')
|
|
96
|
+
database = config.get('database') or os.environ.get('QUANTUM_DB_NAME', 'quantum')
|
|
97
|
+
user = config.get('user') or os.environ.get('QUANTUM_DB_USER', 'postgres')
|
|
98
|
+
password = config.get('password') or os.environ.get('QUANTUM_DB_PASSWORD', '')
|
|
99
|
+
|
|
100
|
+
self._conn = psycopg2.connect(
|
|
101
|
+
host=host,
|
|
102
|
+
port=int(port),
|
|
103
|
+
database=database,
|
|
104
|
+
user=user,
|
|
105
|
+
password=password,
|
|
106
|
+
connect_timeout=5
|
|
107
|
+
)
|
|
108
|
+
self._db_type = 'postgres'
|
|
109
|
+
logger.info(f"Connected to PostgreSQL at {host}:{port}/{database}")
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
except ImportError:
|
|
113
|
+
logger.debug("psycopg2 not installed")
|
|
114
|
+
return False
|
|
115
|
+
except Exception as e:
|
|
116
|
+
logger.debug(f"PostgreSQL connection failed: {e}")
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
def _try_sqlite(self, config: Dict) -> bool:
|
|
120
|
+
"""Try to connect to SQLite"""
|
|
121
|
+
try:
|
|
122
|
+
import sqlite3
|
|
123
|
+
|
|
124
|
+
db_path = config.get('sqlite_path') or self.project_path / "data" / "quantum.db"
|
|
125
|
+
db_path = Path(db_path)
|
|
126
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
|
|
128
|
+
self._conn = sqlite3.connect(str(db_path))
|
|
129
|
+
self._db_type = 'sqlite'
|
|
130
|
+
logger.info(f"Connected to SQLite at {db_path}")
|
|
131
|
+
return True
|
|
132
|
+
|
|
133
|
+
except Exception as e:
|
|
134
|
+
logger.error(f"SQLite connection failed: {e}")
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def db_type(self) -> str:
|
|
139
|
+
return self._db_type
|
|
140
|
+
|
|
141
|
+
def execute(self, sql: str, params: tuple = None):
|
|
142
|
+
"""Execute SQL statement"""
|
|
143
|
+
cursor = self._conn.cursor()
|
|
144
|
+
if params:
|
|
145
|
+
cursor.execute(sql, params)
|
|
146
|
+
else:
|
|
147
|
+
cursor.execute(sql)
|
|
148
|
+
return cursor
|
|
149
|
+
|
|
150
|
+
def commit(self):
|
|
151
|
+
self._conn.commit()
|
|
152
|
+
|
|
153
|
+
def rollback(self):
|
|
154
|
+
self._conn.rollback()
|
|
155
|
+
|
|
156
|
+
def close(self):
|
|
157
|
+
if self._conn:
|
|
158
|
+
self._conn.close()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class MigrationRunner:
|
|
162
|
+
"""Runs database migrations"""
|
|
163
|
+
|
|
164
|
+
MIGRATIONS_TABLE = "_migrations"
|
|
165
|
+
|
|
166
|
+
def __init__(self, project_path: Path = None):
|
|
167
|
+
self.project_path = project_path or Path.cwd()
|
|
168
|
+
self.migrations_dir = self.project_path / "migrations"
|
|
169
|
+
self.db = DatabaseConnection(self.project_path)
|
|
170
|
+
|
|
171
|
+
def _ensure_migrations_table(self):
|
|
172
|
+
"""Create migrations tracking table if it doesn't exist"""
|
|
173
|
+
if self.db.db_type == 'postgres':
|
|
174
|
+
sql = f"""
|
|
175
|
+
CREATE TABLE IF NOT EXISTS {self.MIGRATIONS_TABLE} (
|
|
176
|
+
version VARCHAR(50) PRIMARY KEY,
|
|
177
|
+
name VARCHAR(255) NOT NULL,
|
|
178
|
+
checksum VARCHAR(32) NOT NULL,
|
|
179
|
+
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
180
|
+
)
|
|
181
|
+
"""
|
|
182
|
+
else: # SQLite
|
|
183
|
+
sql = f"""
|
|
184
|
+
CREATE TABLE IF NOT EXISTS {self.MIGRATIONS_TABLE} (
|
|
185
|
+
version TEXT PRIMARY KEY,
|
|
186
|
+
name TEXT NOT NULL,
|
|
187
|
+
checksum TEXT NOT NULL,
|
|
188
|
+
applied_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
189
|
+
)
|
|
190
|
+
"""
|
|
191
|
+
self.db.execute(sql)
|
|
192
|
+
self.db.commit()
|
|
193
|
+
|
|
194
|
+
def _get_applied_migrations(self) -> Dict[str, Dict]:
|
|
195
|
+
"""Get list of already applied migrations"""
|
|
196
|
+
cursor = self.db.execute(
|
|
197
|
+
f"SELECT version, name, checksum, applied_at FROM {self.MIGRATIONS_TABLE} ORDER BY version"
|
|
198
|
+
)
|
|
199
|
+
rows = cursor.fetchall()
|
|
200
|
+
return {
|
|
201
|
+
row[0]: {
|
|
202
|
+
'version': row[0],
|
|
203
|
+
'name': row[1],
|
|
204
|
+
'checksum': row[2],
|
|
205
|
+
'applied_at': row[3]
|
|
206
|
+
}
|
|
207
|
+
for row in rows
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
def _get_pending_migrations(self) -> List[Migration]:
|
|
211
|
+
"""Get list of migrations that need to be applied"""
|
|
212
|
+
if not self.migrations_dir.exists():
|
|
213
|
+
return []
|
|
214
|
+
|
|
215
|
+
applied = self._get_applied_migrations()
|
|
216
|
+
pending = []
|
|
217
|
+
|
|
218
|
+
for f in sorted(self.migrations_dir.glob("*.sql")):
|
|
219
|
+
# `create` writes a PAIR: V001_name.sql and V001_name.down.sql.
|
|
220
|
+
# This glob used to pick up both, and the rollback file sorted
|
|
221
|
+
# FIRST ('.down.sql' < '.sql'), so `migrate up` executed the
|
|
222
|
+
# rollback — DROP TABLE and all — before the migration it belongs
|
|
223
|
+
# to, and then recorded the version as applied. On an already
|
|
224
|
+
# migrated database that is data destruction from a command whose
|
|
225
|
+
# entire job is to move forward. Reproduced before fixing.
|
|
226
|
+
if f.name.endswith('.down.sql'):
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
# Expected format: V001_create_users.sql
|
|
230
|
+
parts = f.stem.split('_', 1)
|
|
231
|
+
if len(parts) < 2:
|
|
232
|
+
continue
|
|
233
|
+
|
|
234
|
+
version = parts[0]
|
|
235
|
+
name = parts[1]
|
|
236
|
+
|
|
237
|
+
if version not in applied:
|
|
238
|
+
pending.append(Migration(version, name, f))
|
|
239
|
+
|
|
240
|
+
return pending
|
|
241
|
+
|
|
242
|
+
def _record_migration(self, migration: Migration):
|
|
243
|
+
"""Record that a migration was applied"""
|
|
244
|
+
if self.db.db_type == 'postgres':
|
|
245
|
+
sql = f"""
|
|
246
|
+
INSERT INTO {self.MIGRATIONS_TABLE} (version, name, checksum)
|
|
247
|
+
VALUES (%s, %s, %s)
|
|
248
|
+
"""
|
|
249
|
+
else: # SQLite
|
|
250
|
+
sql = f"""
|
|
251
|
+
INSERT INTO {self.MIGRATIONS_TABLE} (version, name, checksum)
|
|
252
|
+
VALUES (?, ?, ?)
|
|
253
|
+
"""
|
|
254
|
+
self.db.execute(sql, (migration.version, migration.name, migration.checksum))
|
|
255
|
+
|
|
256
|
+
def _remove_migration_record(self, version: str):
|
|
257
|
+
"""Remove migration record for rollback"""
|
|
258
|
+
if self.db.db_type == 'postgres':
|
|
259
|
+
self.db.execute(f"DELETE FROM {self.MIGRATIONS_TABLE} WHERE version = %s", (version,))
|
|
260
|
+
else:
|
|
261
|
+
self.db.execute(f"DELETE FROM {self.MIGRATIONS_TABLE} WHERE version = ?", (version,))
|
|
262
|
+
|
|
263
|
+
def status(self) -> Dict:
|
|
264
|
+
"""Get migration status"""
|
|
265
|
+
self.db.connect()
|
|
266
|
+
self._ensure_migrations_table()
|
|
267
|
+
|
|
268
|
+
applied = self._get_applied_migrations()
|
|
269
|
+
pending = self._get_pending_migrations()
|
|
270
|
+
|
|
271
|
+
self.db.close()
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
'database_type': self.db.db_type,
|
|
275
|
+
'applied': list(applied.values()),
|
|
276
|
+
'pending': [{'version': m.version, 'name': m.name} for m in pending],
|
|
277
|
+
'total_applied': len(applied),
|
|
278
|
+
'total_pending': len(pending)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
def up(self, count: int = None) -> List[Dict]:
|
|
282
|
+
"""Apply pending migrations"""
|
|
283
|
+
self.db.connect()
|
|
284
|
+
self._ensure_migrations_table()
|
|
285
|
+
|
|
286
|
+
pending = self._get_pending_migrations()
|
|
287
|
+
if count:
|
|
288
|
+
pending = pending[:count]
|
|
289
|
+
|
|
290
|
+
results = []
|
|
291
|
+
|
|
292
|
+
for migration in pending:
|
|
293
|
+
try:
|
|
294
|
+
click.echo(f"Applying {migration.version}: {migration.name}...")
|
|
295
|
+
|
|
296
|
+
# Execute migration SQL
|
|
297
|
+
self.db.execute(migration.content)
|
|
298
|
+
|
|
299
|
+
# Record migration
|
|
300
|
+
self._record_migration(migration)
|
|
301
|
+
self.db.commit()
|
|
302
|
+
|
|
303
|
+
results.append({
|
|
304
|
+
'version': migration.version,
|
|
305
|
+
'name': migration.name,
|
|
306
|
+
'status': 'applied',
|
|
307
|
+
'checksum': migration.checksum
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
click.echo(f" [OK] Applied {migration.version}")
|
|
311
|
+
|
|
312
|
+
except Exception as e:
|
|
313
|
+
self.db.rollback()
|
|
314
|
+
results.append({
|
|
315
|
+
'version': migration.version,
|
|
316
|
+
'name': migration.name,
|
|
317
|
+
'status': 'failed',
|
|
318
|
+
'error': str(e)
|
|
319
|
+
})
|
|
320
|
+
click.echo(f" [FAILED] {e}")
|
|
321
|
+
break # Stop on first failure
|
|
322
|
+
|
|
323
|
+
self.db.close()
|
|
324
|
+
return results
|
|
325
|
+
|
|
326
|
+
def down(self, count: int = 1) -> List[Dict]:
|
|
327
|
+
"""Rollback migrations"""
|
|
328
|
+
self.db.connect()
|
|
329
|
+
self._ensure_migrations_table()
|
|
330
|
+
|
|
331
|
+
applied = self._get_applied_migrations()
|
|
332
|
+
if not applied:
|
|
333
|
+
click.echo("No migrations to rollback")
|
|
334
|
+
self.db.close()
|
|
335
|
+
return []
|
|
336
|
+
|
|
337
|
+
# Get migrations in reverse order
|
|
338
|
+
versions = sorted(applied.keys(), reverse=True)[:count]
|
|
339
|
+
results = []
|
|
340
|
+
|
|
341
|
+
for version in versions:
|
|
342
|
+
migration_info = applied[version]
|
|
343
|
+
|
|
344
|
+
# Look for down migration file
|
|
345
|
+
down_file = self.migrations_dir / f"{version}_{migration_info['name']}.down.sql"
|
|
346
|
+
|
|
347
|
+
if not down_file.exists():
|
|
348
|
+
click.echo(f"Warning: No down migration for {version}")
|
|
349
|
+
results.append({
|
|
350
|
+
'version': version,
|
|
351
|
+
'status': 'skipped',
|
|
352
|
+
'reason': 'No down migration file'
|
|
353
|
+
})
|
|
354
|
+
continue
|
|
355
|
+
|
|
356
|
+
try:
|
|
357
|
+
click.echo(f"Rolling back {version}: {migration_info['name']}...")
|
|
358
|
+
|
|
359
|
+
# Execute rollback SQL
|
|
360
|
+
self.db.execute(down_file.read_text())
|
|
361
|
+
|
|
362
|
+
# Remove migration record
|
|
363
|
+
self._remove_migration_record(version)
|
|
364
|
+
self.db.commit()
|
|
365
|
+
|
|
366
|
+
results.append({
|
|
367
|
+
'version': version,
|
|
368
|
+
'name': migration_info['name'],
|
|
369
|
+
'status': 'rolled_back'
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
click.echo(f" [OK] Rolled back {version}")
|
|
373
|
+
|
|
374
|
+
except Exception as e:
|
|
375
|
+
self.db.rollback()
|
|
376
|
+
results.append({
|
|
377
|
+
'version': version,
|
|
378
|
+
'status': 'failed',
|
|
379
|
+
'error': str(e)
|
|
380
|
+
})
|
|
381
|
+
click.echo(f" [FAILED] {e}")
|
|
382
|
+
break
|
|
383
|
+
|
|
384
|
+
self.db.close()
|
|
385
|
+
return results
|
|
386
|
+
|
|
387
|
+
def create(self, name: str) -> Path:
|
|
388
|
+
"""Create a new migration file"""
|
|
389
|
+
self.migrations_dir.mkdir(parents=True, exist_ok=True)
|
|
390
|
+
|
|
391
|
+
# Get next version number
|
|
392
|
+
existing = sorted(self.migrations_dir.glob("V*.sql"))
|
|
393
|
+
if existing:
|
|
394
|
+
last_version = existing[-1].stem.split('_')[0]
|
|
395
|
+
next_num = int(last_version[1:]) + 1
|
|
396
|
+
else:
|
|
397
|
+
next_num = 1
|
|
398
|
+
|
|
399
|
+
version = f"V{next_num:03d}"
|
|
400
|
+
filename = f"{version}_{name}.sql"
|
|
401
|
+
filepath = self.migrations_dir / filename
|
|
402
|
+
|
|
403
|
+
# Create template
|
|
404
|
+
template = f"""-- Migration: {name}
|
|
405
|
+
-- Version: {version}
|
|
406
|
+
-- Created: {datetime.now().isoformat()}
|
|
407
|
+
|
|
408
|
+
-- Write your SQL here
|
|
409
|
+
|
|
410
|
+
"""
|
|
411
|
+
filepath.write_text(template)
|
|
412
|
+
|
|
413
|
+
# Also create down file
|
|
414
|
+
down_filepath = self.migrations_dir / f"{version}_{name}.down.sql"
|
|
415
|
+
down_template = f"""-- Rollback Migration: {name}
|
|
416
|
+
-- Version: {version}
|
|
417
|
+
|
|
418
|
+
-- Write your rollback SQL here
|
|
419
|
+
|
|
420
|
+
"""
|
|
421
|
+
down_filepath.write_text(down_template)
|
|
422
|
+
|
|
423
|
+
return filepath
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
# CLI Commands
|
|
427
|
+
|
|
428
|
+
@click.group()
|
|
429
|
+
def migrate():
|
|
430
|
+
"""Database migration commands"""
|
|
431
|
+
pass
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@migrate.command()
|
|
435
|
+
@click.option('--json', 'as_json', is_flag=True, help='Output as JSON')
|
|
436
|
+
def status(as_json: bool):
|
|
437
|
+
"""Show migration status"""
|
|
438
|
+
runner = MigrationRunner()
|
|
439
|
+
|
|
440
|
+
try:
|
|
441
|
+
result = runner.status()
|
|
442
|
+
|
|
443
|
+
if as_json:
|
|
444
|
+
import json
|
|
445
|
+
click.echo(json.dumps(result, indent=2, default=str))
|
|
446
|
+
else:
|
|
447
|
+
click.echo(f"\nDatabase: {result['database_type']}")
|
|
448
|
+
click.echo(f"Applied migrations: {result['total_applied']}")
|
|
449
|
+
click.echo(f"Pending migrations: {result['total_pending']}")
|
|
450
|
+
|
|
451
|
+
if result['applied']:
|
|
452
|
+
click.echo("\nApplied:")
|
|
453
|
+
for m in result['applied']:
|
|
454
|
+
click.echo(f" {m['version']}: {m['name']} (applied: {m['applied_at']})")
|
|
455
|
+
|
|
456
|
+
if result['pending']:
|
|
457
|
+
click.echo("\nPending:")
|
|
458
|
+
for m in result['pending']:
|
|
459
|
+
click.echo(f" {m['version']}: {m['name']}")
|
|
460
|
+
|
|
461
|
+
except Exception as e:
|
|
462
|
+
click.echo(f"Error: {e}", err=True)
|
|
463
|
+
sys.exit(1)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
@migrate.command()
|
|
467
|
+
@click.option('--count', '-n', type=int, help='Number of migrations to apply')
|
|
468
|
+
def up(count: Optional[int]):
|
|
469
|
+
"""Apply pending migrations"""
|
|
470
|
+
runner = MigrationRunner()
|
|
471
|
+
|
|
472
|
+
try:
|
|
473
|
+
results = runner.up(count=count)
|
|
474
|
+
|
|
475
|
+
applied = sum(1 for r in results if r.get('status') == 'applied')
|
|
476
|
+
failed = sum(1 for r in results if r.get('status') == 'failed')
|
|
477
|
+
|
|
478
|
+
click.echo(f"\nApplied: {applied}, Failed: {failed}")
|
|
479
|
+
|
|
480
|
+
if failed:
|
|
481
|
+
sys.exit(1)
|
|
482
|
+
|
|
483
|
+
except Exception as e:
|
|
484
|
+
click.echo(f"Error: {e}", err=True)
|
|
485
|
+
sys.exit(1)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
@migrate.command()
|
|
489
|
+
@click.option('--count', '-n', type=int, default=1, help='Number of migrations to rollback')
|
|
490
|
+
def down(count: int):
|
|
491
|
+
"""Rollback migrations"""
|
|
492
|
+
runner = MigrationRunner()
|
|
493
|
+
|
|
494
|
+
try:
|
|
495
|
+
results = runner.down(count=count)
|
|
496
|
+
|
|
497
|
+
rolled_back = sum(1 for r in results if r.get('status') == 'rolled_back')
|
|
498
|
+
failed = sum(1 for r in results if r.get('status') == 'failed')
|
|
499
|
+
|
|
500
|
+
click.echo(f"\nRolled back: {rolled_back}, Failed: {failed}")
|
|
501
|
+
|
|
502
|
+
if failed:
|
|
503
|
+
sys.exit(1)
|
|
504
|
+
|
|
505
|
+
except Exception as e:
|
|
506
|
+
click.echo(f"Error: {e}", err=True)
|
|
507
|
+
sys.exit(1)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
@migrate.command()
|
|
511
|
+
@click.argument('name')
|
|
512
|
+
def create(name: str):
|
|
513
|
+
"""Create a new migration"""
|
|
514
|
+
runner = MigrationRunner()
|
|
515
|
+
|
|
516
|
+
try:
|
|
517
|
+
filepath = runner.create(name)
|
|
518
|
+
click.echo(f"Created migration: {filepath}")
|
|
519
|
+
click.echo(f"Also created rollback: {filepath.with_suffix('.down.sql')}")
|
|
520
|
+
|
|
521
|
+
except Exception as e:
|
|
522
|
+
click.echo(f"Error: {e}", err=True)
|
|
523
|
+
sys.exit(1)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
if __name__ == '__main__':
|
|
527
|
+
migrate()
|