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.
Files changed (262) hide show
  1. quantum/__init__.py +1 -0
  2. quantum/cli/__init__.py +2 -0
  3. quantum/cli/commands/__init__.py +15 -0
  4. quantum/cli/commands/build.py +417 -0
  5. quantum/cli/commands/dev.py +185 -0
  6. quantum/cli/commands/docs.py +329 -0
  7. quantum/cli/commands/lint.py +523 -0
  8. quantum/cli/commands/migrate.py +527 -0
  9. quantum/cli/commands/new.py +622 -0
  10. quantum/cli/commands/serve.py +190 -0
  11. quantum/cli/commands/test.py +193 -0
  12. quantum/cli/deploy.py +810 -0
  13. quantum/cli/hot_reload.py +951 -0
  14. quantum/cli/jobs.py +356 -0
  15. quantum/cli/mq.py +582 -0
  16. quantum/cli/pkg.py +390 -0
  17. quantum/cli/runner.py +547 -0
  18. quantum/cli/server_process.py +159 -0
  19. quantum/cli/utils.py +334 -0
  20. quantum/compiler/__init__.py +30 -0
  21. quantum/compiler/base_generator.py +367 -0
  22. quantum/compiler/cli.py +295 -0
  23. quantum/compiler/expression_transformer.py +444 -0
  24. quantum/compiler/javascript/__init__.py +10 -0
  25. quantum/compiler/javascript/generator.py +659 -0
  26. quantum/compiler/optimizer.py +270 -0
  27. quantum/compiler/python/__init__.py +10 -0
  28. quantum/compiler/python/generator.py +883 -0
  29. quantum/compiler/python/runtime.py +863 -0
  30. quantum/compiler/transpiler.py +330 -0
  31. quantum/core/__init__.py +3 -0
  32. quantum/core/ast_nodes.py +2611 -0
  33. quantum/core/expression_diagnostics.py +87 -0
  34. quantum/core/expression_stdlib.py +148 -0
  35. quantum/core/expressions.py +591 -0
  36. quantum/core/features/agents/src/__init__.py +30 -0
  37. quantum/core/features/agents/src/ast_node.py +540 -0
  38. quantum/core/features/conditionals/src/__init__.py +8 -0
  39. quantum/core/features/conditionals/src/ast_node.py +68 -0
  40. quantum/core/features/data_fetching/src/__init__.py +21 -0
  41. quantum/core/features/data_fetching/src/ast_node.py +312 -0
  42. quantum/core/features/data_fetching/src/desktop_adapter.py +351 -0
  43. quantum/core/features/data_fetching/src/html_adapter.py +474 -0
  44. quantum/core/features/data_fetching/src/parser.py +225 -0
  45. quantum/core/features/data_import/src/__init__.py +0 -0
  46. quantum/core/features/data_import/src/ast_node.py +291 -0
  47. quantum/core/features/data_import/src/runtime.py +538 -0
  48. quantum/core/features/dump/src/__init__.py +12 -0
  49. quantum/core/features/dump/src/ast_node.py +106 -0
  50. quantum/core/features/dump/src/parser.py +61 -0
  51. quantum/core/features/dump/src/runtime.py +246 -0
  52. quantum/core/features/functions/src/__init__.py +8 -0
  53. quantum/core/features/functions/src/ast_node.py +149 -0
  54. quantum/core/features/game_engine_2d/src/__init__.py +19 -0
  55. quantum/core/features/game_engine_2d/src/ast_nodes.py +1719 -0
  56. quantum/core/features/game_engine_2d/src/parser.py +983 -0
  57. quantum/core/features/invocation/src/__init__.py +0 -0
  58. quantum/core/features/invocation/src/ast_node.py +146 -0
  59. quantum/core/features/invocation/src/runtime.py +327 -0
  60. quantum/core/features/knowledge_base/src/__init__.py +6 -0
  61. quantum/core/features/knowledge_base/src/ast_node.py +113 -0
  62. quantum/core/features/knowledge_base/src/parser.py +82 -0
  63. quantum/core/features/logging/src/__init__.py +12 -0
  64. quantum/core/features/logging/src/ast_node.py +111 -0
  65. quantum/core/features/logging/src/parser.py +50 -0
  66. quantum/core/features/logging/src/runtime.py +190 -0
  67. quantum/core/features/loops/src/__init__.py +8 -0
  68. quantum/core/features/loops/src/ast_node.py +60 -0
  69. quantum/core/features/query/src/__init__.py +0 -0
  70. quantum/core/features/query/src/database_service.py +322 -0
  71. quantum/core/features/query/src/query_validators.py +20 -0
  72. quantum/core/features/state_management/src/__init__.py +11 -0
  73. quantum/core/features/state_management/src/ast_node.py +228 -0
  74. quantum/core/features/terminal_engine/src/__init__.py +21 -0
  75. quantum/core/features/terminal_engine/src/ast_nodes.py +560 -0
  76. quantum/core/features/terminal_engine/src/parser.py +361 -0
  77. quantum/core/features/testing_engine/src/__init__.py +41 -0
  78. quantum/core/features/testing_engine/src/ast_nodes.py +1212 -0
  79. quantum/core/features/testing_engine/src/parser.py +604 -0
  80. quantum/core/features/theming/src/__init__.py +48 -0
  81. quantum/core/features/theming/src/ast_node.py +137 -0
  82. quantum/core/features/theming/src/presets.py +405 -0
  83. quantum/core/features/ui_engine/src/__init__.py +20 -0
  84. quantum/core/features/ui_engine/src/ast_nodes.py +1854 -0
  85. quantum/core/features/ui_engine/src/parser.py +1106 -0
  86. quantum/core/features/websocket/src/__init__.py +24 -0
  87. quantum/core/features/websocket/src/ast_node.py +247 -0
  88. quantum/core/html_compat.py +299 -0
  89. quantum/core/parser.py +1235 -0
  90. quantum/core/parser_registry.py +213 -0
  91. quantum/core/parsers/__init__.py +76 -0
  92. quantum/core/parsers/ai/__init__.py +12 -0
  93. quantum/core/parsers/ai/agent_parser.py +106 -0
  94. quantum/core/parsers/ai/knowledge_parser.py +86 -0
  95. quantum/core/parsers/ai/llm_parser.py +78 -0
  96. quantum/core/parsers/ai/team_parser.py +88 -0
  97. quantum/core/parsers/base.py +322 -0
  98. quantum/core/parsers/composition/__init__.py +10 -0
  99. quantum/core/parsers/composition/import_parser.py +50 -0
  100. quantum/core/parsers/composition/slot_parser.py +48 -0
  101. quantum/core/parsers/control_flow/__init__.py +11 -0
  102. quantum/core/parsers/control_flow/if_parser.py +68 -0
  103. quantum/core/parsers/control_flow/loop_parser.py +105 -0
  104. quantum/core/parsers/control_flow/set_parser.py +89 -0
  105. quantum/core/parsers/data/__init__.py +12 -0
  106. quantum/core/parsers/data/data_parser.py +217 -0
  107. quantum/core/parsers/data/invoke_parser.py +116 -0
  108. quantum/core/parsers/data/query_parser.py +188 -0
  109. quantum/core/parsers/data/transaction_parser.py +77 -0
  110. quantum/core/parsers/events/__init__.py +9 -0
  111. quantum/core/parsers/events/dispatch_event_parser.py +44 -0
  112. quantum/core/parsers/forms/__init__.py +11 -0
  113. quantum/core/parsers/forms/action_parser.py +54 -0
  114. quantum/core/parsers/forms/flash_parser.py +34 -0
  115. quantum/core/parsers/forms/redirect_parser.py +33 -0
  116. quantum/core/parsers/functions/__init__.py +11 -0
  117. quantum/core/parsers/functions/function_parser.py +137 -0
  118. quantum/core/parsers/functions/param_parser.py +66 -0
  119. quantum/core/parsers/functions/return_parser.py +31 -0
  120. quantum/core/parsers/html/__init__.py +10 -0
  121. quantum/core/parsers/html/component_call_parser.py +130 -0
  122. quantum/core/parsers/html/html_parser.py +115 -0
  123. quantum/core/parsers/jobs/__init__.py +11 -0
  124. quantum/core/parsers/jobs/job_parser.py +71 -0
  125. quantum/core/parsers/jobs/schedule_parser.py +62 -0
  126. quantum/core/parsers/jobs/thread_parser.py +57 -0
  127. quantum/core/parsers/messaging/__init__.py +17 -0
  128. quantum/core/parsers/messaging/message_ack_parser.py +30 -0
  129. quantum/core/parsers/messaging/message_nack_parser.py +30 -0
  130. quantum/core/parsers/messaging/message_parser.py +114 -0
  131. quantum/core/parsers/messaging/queue_parser.py +61 -0
  132. quantum/core/parsers/messaging/websocket_parser.py +121 -0
  133. quantum/core/parsers/persistence/__init__.py +9 -0
  134. quantum/core/parsers/persistence/persist_parser.py +64 -0
  135. quantum/core/parsers/routing/__init__.py +9 -0
  136. quantum/core/parsers/routing/route_parser.py +41 -0
  137. quantum/core/parsers/scripting/__init__.py +12 -0
  138. quantum/core/parsers/scripting/pyclass_parser.py +62 -0
  139. quantum/core/parsers/scripting/pydecorator_parser.py +68 -0
  140. quantum/core/parsers/scripting/pyimport_parser.py +52 -0
  141. quantum/core/parsers/scripting/python_parser.py +49 -0
  142. quantum/core/parsers/services/__init__.py +12 -0
  143. quantum/core/parsers/services/dump_parser.py +52 -0
  144. quantum/core/parsers/services/file_parser.py +48 -0
  145. quantum/core/parsers/services/log_parser.py +46 -0
  146. quantum/core/parsers/services/mail_parser.py +65 -0
  147. quantum/core/tiers.py +82 -0
  148. quantum/packages/__init__.py +28 -0
  149. quantum/packages/manager.py +413 -0
  150. quantum/packages/manifest.py +351 -0
  151. quantum/packages/registry.py +399 -0
  152. quantum/packages/resolver.py +336 -0
  153. quantum/plugins/__init__.py +33 -0
  154. quantum/plugins/hooks.py +329 -0
  155. quantum/plugins/loader.py +479 -0
  156. quantum/plugins/manifest.py +336 -0
  157. quantum/plugins/registry.py +371 -0
  158. quantum/runtime/__init__.py +28 -0
  159. quantum/runtime/action_handler.py +443 -0
  160. quantum/runtime/adapters/__init__.py +88 -0
  161. quantum/runtime/adapters/memory_adapter.py +690 -0
  162. quantum/runtime/adapters/rabbitmq_adapter.py +715 -0
  163. quantum/runtime/adapters/redis_adapter.py +582 -0
  164. quantum/runtime/adapters/sqlite_adapter.py +414 -0
  165. quantum/runtime/agent_service.py +1133 -0
  166. quantum/runtime/api_server.py +86 -0
  167. quantum/runtime/ast_cache.py +506 -0
  168. quantum/runtime/auth_service.py +267 -0
  169. quantum/runtime/component.py +990 -0
  170. quantum/runtime/component_composer.py +319 -0
  171. quantum/runtime/component_resolver.py +174 -0
  172. quantum/runtime/database_service.py +598 -0
  173. quantum/runtime/email_service.py +162 -0
  174. quantum/runtime/error_handler.py +295 -0
  175. quantum/runtime/execution_context.py +286 -0
  176. quantum/runtime/executor_registry.py +171 -0
  177. quantum/runtime/executors/__init__.py +71 -0
  178. quantum/runtime/executors/ai/__init__.py +12 -0
  179. quantum/runtime/executors/ai/agent_executor.py +217 -0
  180. quantum/runtime/executors/ai/knowledge_executor.py +114 -0
  181. quantum/runtime/executors/ai/llm_executor.py +153 -0
  182. quantum/runtime/executors/ai/team_executor.py +171 -0
  183. quantum/runtime/executors/base.py +262 -0
  184. quantum/runtime/executors/control_flow/__init__.py +11 -0
  185. quantum/runtime/executors/control_flow/if_executor.py +93 -0
  186. quantum/runtime/executors/control_flow/loop_executor.py +307 -0
  187. quantum/runtime/executors/control_flow/set_executor.py +412 -0
  188. quantum/runtime/executors/data/__init__.py +12 -0
  189. quantum/runtime/executors/data/data_executor.py +145 -0
  190. quantum/runtime/executors/data/invoke_executor.py +176 -0
  191. quantum/runtime/executors/data/query_executor.py +256 -0
  192. quantum/runtime/executors/data/transaction_executor.py +91 -0
  193. quantum/runtime/executors/jobs/__init__.py +11 -0
  194. quantum/runtime/executors/jobs/job_executor.py +190 -0
  195. quantum/runtime/executors/jobs/schedule_executor.py +132 -0
  196. quantum/runtime/executors/jobs/thread_executor.py +127 -0
  197. quantum/runtime/executors/messaging/__init__.py +17 -0
  198. quantum/runtime/executors/messaging/message_ack_executor.py +51 -0
  199. quantum/runtime/executors/messaging/message_executor.py +174 -0
  200. quantum/runtime/executors/messaging/queue_executor.py +103 -0
  201. quantum/runtime/executors/messaging/websocket_executor.py +197 -0
  202. quantum/runtime/executors/scripting/__init__.py +11 -0
  203. quantum/runtime/executors/scripting/pyclass_executor.py +90 -0
  204. quantum/runtime/executors/scripting/pyimport_executor.py +81 -0
  205. quantum/runtime/executors/scripting/python_executor.py +249 -0
  206. quantum/runtime/executors/services/__init__.py +12 -0
  207. quantum/runtime/executors/services/dump_executor.py +72 -0
  208. quantum/runtime/executors/services/file_executor.py +89 -0
  209. quantum/runtime/executors/services/log_executor.py +77 -0
  210. quantum/runtime/executors/services/mail_executor.py +81 -0
  211. quantum/runtime/expression_cache.py +498 -0
  212. quantum/runtime/file_upload_service.py +326 -0
  213. quantum/runtime/function_registry.py +118 -0
  214. quantum/runtime/game_builder.py +166 -0
  215. quantum/runtime/game_code_generator.py +2371 -0
  216. quantum/runtime/game_templates.py +2006 -0
  217. quantum/runtime/godot_code_generator.py +4681 -0
  218. quantum/runtime/godot_templates.py +1449 -0
  219. quantum/runtime/job_executor.py +1599 -0
  220. quantum/runtime/knowledge_service.py +500 -0
  221. quantum/runtime/llm_cache.py +100 -0
  222. quantum/runtime/llm_providers.py +704 -0
  223. quantum/runtime/llm_service.py +287 -0
  224. quantum/runtime/logging_setup.py +140 -0
  225. quantum/runtime/message_broker.py +364 -0
  226. quantum/runtime/message_queue_service.py +571 -0
  227. quantum/runtime/param_validation.py +184 -0
  228. quantum/runtime/pypy_compat.py +315 -0
  229. quantum/runtime/python_bridge.py +698 -0
  230. quantum/runtime/query_validators.py +304 -0
  231. quantum/runtime/renderer.py +733 -0
  232. quantum/runtime/service_container.py +444 -0
  233. quantum/runtime/terminal_builder.py +76 -0
  234. quantum/runtime/terminal_code_generator.py +607 -0
  235. quantum/runtime/terminal_templates.py +243 -0
  236. quantum/runtime/testing_builder.py +77 -0
  237. quantum/runtime/testing_code_generator.py +833 -0
  238. quantum/runtime/testing_templates.py +85 -0
  239. quantum/runtime/ui_builder.py +188 -0
  240. quantum/runtime/ui_desktop_adapter.py +1730 -0
  241. quantum/runtime/ui_desktop_templates.py +307 -0
  242. quantum/runtime/ui_html_adapter.py +2691 -0
  243. quantum/runtime/ui_html_templates.py +2297 -0
  244. quantum/runtime/ui_mobile_adapter.py +1832 -0
  245. quantum/runtime/ui_mobile_templates.py +1003 -0
  246. quantum/runtime/ui_textual_adapter.py +1866 -0
  247. quantum/runtime/ui_textual_templates.py +45 -0
  248. quantum/runtime/ui_tokens.py +465 -0
  249. quantum/runtime/ui_validator.py +365 -0
  250. quantum/runtime/validators.py +256 -0
  251. quantum/runtime/web_server.py +1766 -0
  252. quantum/runtime/websocket_adapter.py +501 -0
  253. quantum/runtime/websocket_service.py +585 -0
  254. quantum/runtime/websocket_transport.py +289 -0
  255. quantum/runtime/wsgi.py +101 -0
  256. quantum/utils/__init__.py +1 -0
  257. quantum_framework-0.9.0.dist-info/METADATA +244 -0
  258. quantum_framework-0.9.0.dist-info/RECORD +262 -0
  259. quantum_framework-0.9.0.dist-info/WHEEL +5 -0
  260. quantum_framework-0.9.0.dist-info/entry_points.txt +2 -0
  261. quantum_framework-0.9.0.dist-info/licenses/LICENSE +21 -0
  262. quantum_framework-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,598 @@
1
+ """
2
+ Quantum Database Service - Manages database connections and query execution
3
+ """
4
+
5
+ import logging
6
+ import time
7
+ import requests
8
+ from typing import Dict, Any, List, Optional
9
+ from dataclasses import dataclass, asdict
10
+
11
+ logger = logging.getLogger('quantum.database')
12
+
13
+
14
+ @dataclass
15
+ class QueryResult:
16
+ """Container for query results and metadata"""
17
+ data: List[Dict[str, Any]]
18
+ column_list: List[str]
19
+ execution_time: float # in milliseconds
20
+ record_count: int
21
+ sql: Optional[str] = None # Set in debug mode
22
+ page: Optional[int] = None
23
+ total_pages: Optional[int] = None
24
+ has_more: bool = False
25
+ cached: bool = False
26
+ success: bool = True
27
+ affected_rows: Optional[int] = None # For UPDATE/DELETE operations
28
+ last_insert_id: Optional[int] = None # For INSERT operations
29
+
30
+ def to_dict(self):
31
+ """Convert to dictionary for template access"""
32
+ result = asdict(self)
33
+ # Rename column_list to columnList for JavaScript compatibility
34
+ result['columnList'] = result.pop('column_list')
35
+ result['recordCount'] = result.pop('record_count')
36
+ result['executionTime'] = result.pop('execution_time')
37
+ result['totalPages'] = result.pop('total_pages')
38
+ result['hasMore'] = result.pop('has_more')
39
+ result['affectedRows'] = result.pop('affected_rows')
40
+ result['lastInsertId'] = result.pop('last_insert_id')
41
+ return result
42
+
43
+
44
+ class DatabaseConnectionError(Exception):
45
+ """Raised when database connection fails"""
46
+ pass
47
+
48
+
49
+ class QueryExecutionError(Exception):
50
+ """Raised when query execution fails"""
51
+ pass
52
+
53
+
54
+ class DatabaseService:
55
+ """Manages database connections and query execution"""
56
+
57
+ def get_datasource_config(self, datasource_name: str) -> Dict[str, Any]:
58
+ """
59
+ Get datasource configuration. Checks local config first, then Admin API.
60
+
61
+ Args:
62
+ datasource_name: Name of the datasource
63
+
64
+ Returns:
65
+ Dict with datasource configuration
66
+
67
+ Raises:
68
+ DatabaseConnectionError: If datasource not found or not ready
69
+ """
70
+ # Check local datasources first (from quantum.config.yaml)
71
+ if datasource_name in self.local_datasources:
72
+ local_cfg = self.local_datasources[datasource_name]
73
+ # Allow env var override for database path (e.g., QUANTUM_TASKDB_PATH)
74
+ import os
75
+ env_key = f"QUANTUM_{datasource_name.upper()}_PATH"
76
+ db_path = os.environ.get(env_key, local_cfg.get('database', ''))
77
+ # Normalize config to match Admin API format
78
+ return {
79
+ 'name': datasource_name,
80
+ 'type': local_cfg.get('driver', local_cfg.get('type', 'sqlite')),
81
+ 'database': db_path,
82
+ 'database_name': db_path,
83
+ 'host': local_cfg.get('host', 'localhost'),
84
+ 'port': local_cfg.get('port', 5432),
85
+ 'username': local_cfg.get('username', ''),
86
+ 'password': local_cfg.get('password', ''),
87
+ }
88
+
89
+ try:
90
+ url = f"{self.admin_api_url}/api/datasources/by-name/{datasource_name}"
91
+ response = requests.get(url, timeout=5)
92
+
93
+ if response.status_code == 404:
94
+ raise DatabaseConnectionError(f"Datasource '{datasource_name}' not found")
95
+ elif response.status_code == 503:
96
+ raise DatabaseConnectionError(
97
+ f"Datasource '{datasource_name}' is not ready. Please check Quantum Admin."
98
+ )
99
+ elif response.status_code != 200:
100
+ raise DatabaseConnectionError(
101
+ f"Failed to fetch datasource '{datasource_name}': {response.status_code}"
102
+ )
103
+
104
+ return response.json()
105
+
106
+ except requests.exceptions.ConnectionError:
107
+ # Lead with the local path: declaring the datasource in
108
+ # quantum.config.yaml is the normal way to do this and needs no
109
+ # other process. Quantum Admin is the optional, centrally-managed
110
+ # alternative — pointing at it first sent every new user chasing a
111
+ # server they do not need.
112
+ raise DatabaseConnectionError(
113
+ f"Datasource '{datasource_name}' is not declared locally, and "
114
+ f"the optional Quantum Admin API at {self.admin_api_url} is not "
115
+ f"reachable.\n"
116
+ f" Declare it in quantum.config.yaml:\n"
117
+ f" datasources:\n"
118
+ f" {datasource_name}:\n"
119
+ f" driver: sqlite\n"
120
+ f" database: ./data/app.db\n"
121
+ f" Or start Quantum Admin if you manage datasources there."
122
+ )
123
+ except requests.exceptions.Timeout:
124
+ raise DatabaseConnectionError(
125
+ f"Timeout connecting to Quantum Admin API at {self.admin_api_url}"
126
+ )
127
+
128
+ def get_connection(self, datasource_config: Dict[str, Any]):
129
+ """
130
+ Get or create database connection
131
+
132
+ Args:
133
+ datasource_config: Datasource configuration from Admin API
134
+
135
+ Returns:
136
+ Database connection object
137
+
138
+ Raises:
139
+ DatabaseConnectionError: If connection fails
140
+ """
141
+ datasource_name = datasource_config['name']
142
+ db_type = datasource_config['type']
143
+
144
+ # Check if connection already exists in pool
145
+ if datasource_name in self.connection_pool:
146
+ conn = self.connection_pool[datasource_name]
147
+ # Test connection is still alive
148
+ if self._test_connection(conn, db_type):
149
+ return conn
150
+ else:
151
+ # Connection dead, remove from pool
152
+ del self.connection_pool[datasource_name]
153
+
154
+ # Create new connection
155
+ try:
156
+ if db_type == 'postgresql':
157
+ conn = self._create_postgres_connection(datasource_config)
158
+ elif db_type in ['mysql', 'mariadb']:
159
+ conn = self._create_mysql_connection(datasource_config)
160
+ elif db_type == 'sqlite':
161
+ conn = self._create_sqlite_connection(datasource_config)
162
+ else:
163
+ raise DatabaseConnectionError(f"Unsupported database type: {db_type}")
164
+
165
+ # Store in pool
166
+ self.connection_pool[datasource_name] = conn
167
+ return conn
168
+
169
+ except Exception as e:
170
+ raise DatabaseConnectionError(f"Failed to connect to database: {e}")
171
+
172
+ def _open_raw_connection(self, datasource_config: Dict[str, Any]):
173
+ """A fresh connection that bypasses the pool, for a transaction to own.
174
+
175
+ Reuses the same per-driver factories as get_connection, but never
176
+ stores the result in connection_pool — a connection with an open
177
+ transaction must not be handed to unrelated queries.
178
+ """
179
+ db_type = datasource_config['type']
180
+ if db_type == 'postgresql':
181
+ return self._create_postgres_connection(datasource_config)
182
+ if db_type in ('mysql', 'mariadb'):
183
+ return self._create_mysql_connection(datasource_config)
184
+ if db_type == 'sqlite':
185
+ return self._create_sqlite_connection(datasource_config)
186
+ raise DatabaseConnectionError(f"Unsupported database type: {db_type}")
187
+
188
+ def _create_postgres_connection(self, config: Dict[str, Any]):
189
+ """Create PostgreSQL connection"""
190
+ try:
191
+ import psycopg2
192
+ return psycopg2.connect(
193
+ host=config['host'],
194
+ port=config['port'],
195
+ database=config['database_name'],
196
+ user=config['username'],
197
+ password=config['password']
198
+ )
199
+ except ImportError:
200
+ raise DatabaseConnectionError(
201
+ "psycopg2 not installed. Install with: pip install psycopg2-binary"
202
+ )
203
+
204
+ def _create_mysql_connection(self, config: Dict[str, Any]):
205
+ """Create MySQL/MariaDB connection"""
206
+ try:
207
+ import pymysql
208
+ return pymysql.connect(
209
+ host=config['host'],
210
+ port=config['port'],
211
+ database=config['database_name'],
212
+ user=config['username'],
213
+ password=config['password'],
214
+ cursorclass=pymysql.cursors.DictCursor # Return results as dictionaries
215
+ )
216
+ except ImportError:
217
+ raise DatabaseConnectionError(
218
+ "pymysql not installed. Install with: pip install pymysql"
219
+ )
220
+
221
+ def _create_sqlite_connection(self, config: Dict[str, Any]):
222
+ """Create SQLite connection"""
223
+ import sqlite3
224
+ conn = sqlite3.connect(config['database'], timeout=30.0)
225
+ conn.row_factory = sqlite3.Row # Enable column access by name
226
+ conn.isolation_level = None # Autocommit mode for better concurrency
227
+ return conn
228
+
229
+ def _test_connection(self, conn, db_type: str) -> bool:
230
+ """Test if connection is still alive"""
231
+ try:
232
+ cursor = conn.cursor()
233
+ if db_type == 'postgresql':
234
+ cursor.execute("SELECT 1")
235
+ elif db_type in ['mysql', 'mariadb']:
236
+ cursor.execute("SELECT 1")
237
+ cursor.close()
238
+ return True
239
+ except Exception as exc:
240
+ # Returning False IS the contract here — the caller drops the
241
+ # pooled connection and opens a new one. But the reason is worth
242
+ # having at debug level: a pool that keeps churning is usually a
243
+ # network or credential problem, and the exception was the only
244
+ # evidence.
245
+ logger.debug("connection test failed, dropping from pool: %s", exc)
246
+ return False
247
+
248
+ def execute_query(self, datasource_name: str, sql: str, params: Dict[str, Any] = None) -> QueryResult:
249
+ """
250
+ Execute SQL with parameter binding
251
+
252
+ Args:
253
+ datasource_name: Name of the datasource
254
+ sql: SQL query with :param placeholders
255
+ params: Dictionary of parameter values
256
+
257
+ Returns:
258
+ QueryResult with data and metadata
259
+
260
+ Raises:
261
+ QueryExecutionError: If query execution fails
262
+ """
263
+ if params is None:
264
+ params = {}
265
+
266
+ # Get datasource config
267
+ config = self.get_datasource_config(datasource_name)
268
+ db_type = config['type']
269
+
270
+ # Inside a q:transaction, every query on this datasource runs on the
271
+ # transaction's dedicated connection and must NOT commit — otherwise
272
+ # each write autocommits and rollback has nothing to undo.
273
+ tx_conn = self._active_transaction_for(datasource_name)
274
+ in_transaction = tx_conn is not None
275
+ conn = tx_conn if in_transaction else self.get_connection(config)
276
+
277
+ # Start timing
278
+ start_time = time.time()
279
+
280
+ try:
281
+ cursor = conn.cursor()
282
+
283
+ # Convert :param syntax to database-specific placeholder
284
+ prepared_sql, prepared_params = self._prepare_query(sql, params, db_type)
285
+
286
+ # Execute query
287
+ cursor.execute(prepared_sql, prepared_params)
288
+
289
+ # Check if it's a SELECT query (returns data)
290
+ if cursor.description:
291
+ # Fetch results
292
+ rows = cursor.fetchall()
293
+
294
+ # Get column names
295
+ if db_type == 'postgresql':
296
+ column_names = [desc[0] for desc in cursor.description]
297
+ # Convert rows to dictionaries for PostgreSQL
298
+ data = [dict(zip(column_names, row)) for row in rows]
299
+ elif db_type in ['mysql', 'mariadb']:
300
+ # pymysql with DictCursor already returns dictionaries
301
+ column_names = list(rows[0].keys()) if rows else []
302
+ data = rows
303
+ elif db_type == 'sqlite':
304
+ # SQLite with row_factory=Row - convert to dictionaries
305
+ column_names = [desc[0] for desc in cursor.description]
306
+ data = [dict(row) for row in rows]
307
+ else:
308
+ column_names = []
309
+ data = []
310
+
311
+ record_count = len(data)
312
+ affected_rows = None
313
+ last_insert_id = None
314
+ else:
315
+ # Non-SELECT query (INSERT, UPDATE, DELETE). Commit now ONLY
316
+ # when standalone; inside a transaction the commit is deferred
317
+ # to commit_transaction so a later failure can roll it back.
318
+ if not in_transaction:
319
+ conn.commit()
320
+ data = []
321
+ column_names = []
322
+ record_count = cursor.rowcount
323
+ affected_rows = cursor.rowcount
324
+ last_insert_id = None
325
+
326
+ # Get last insert ID for INSERT operations
327
+ if db_type == 'postgresql':
328
+ # PostgreSQL: Try to get lastval() if available
329
+ try:
330
+ id_cursor = conn.cursor()
331
+ id_cursor.execute("SELECT lastval()")
332
+ last_insert_id = id_cursor.fetchone()[0]
333
+ id_cursor.close()
334
+ except:
335
+ # No sequence was used (e.g., UPDATE/DELETE)
336
+ pass
337
+ elif db_type in ['mysql', 'mariadb']:
338
+ # MySQL/MariaDB: Use cursor.lastrowid
339
+ if cursor.lastrowid > 0:
340
+ last_insert_id = cursor.lastrowid
341
+ elif db_type == 'sqlite':
342
+ # SQLite: Use cursor.lastrowid
343
+ if cursor.lastrowid > 0:
344
+ last_insert_id = cursor.lastrowid
345
+
346
+ # For INSERT with RETURNING (PostgreSQL)
347
+ if db_type == 'postgresql' and cursor.description:
348
+ rows = cursor.fetchall()
349
+ column_names = [desc[0] for desc in cursor.description]
350
+ data = [dict(zip(column_names, row)) for row in rows]
351
+ record_count = len(data)
352
+
353
+ cursor.close()
354
+
355
+ # Calculate execution time
356
+ execution_time = (time.time() - start_time) * 1000 # Convert to ms
357
+
358
+ return QueryResult(
359
+ data=data,
360
+ column_list=column_names,
361
+ execution_time=execution_time,
362
+ record_count=record_count,
363
+ affected_rows=affected_rows,
364
+ last_insert_id=last_insert_id
365
+ )
366
+
367
+ except Exception as e:
368
+ # Standalone: roll back this one statement. Inside a transaction,
369
+ # let the error propagate to the TransactionExecutor so the WHOLE
370
+ # transaction rolls back — rolling back here would abandon the rest
371
+ # of the transaction's connection state.
372
+ if not in_transaction:
373
+ try:
374
+ conn.rollback()
375
+ except Exception:
376
+ # Best effort: we are already raising the real error, and
377
+ # a failed rollback on a broken connection must not
378
+ # replace it. Deliberately silent.
379
+ pass
380
+ raise QueryExecutionError(f"Query execution failed: {e}")
381
+
382
+ def _prepare_query(self, sql: str, params: Dict[str, Any], db_type: str) -> tuple:
383
+ """
384
+ Convert :param syntax to database-specific placeholders
385
+
386
+ Args:
387
+ sql: SQL with :param placeholders
388
+ params: Dictionary of parameters
389
+ db_type: Database type (postgresql, mysql, mariadb)
390
+
391
+ Returns:
392
+ Tuple of (prepared_sql, prepared_params)
393
+ """
394
+ import re
395
+
396
+ # Find all :param placeholders
397
+ param_pattern = r':(\w+)'
398
+ param_names = re.findall(param_pattern, sql)
399
+
400
+ if db_type == 'postgresql':
401
+ # PostgreSQL uses %s placeholders
402
+ prepared_sql = sql
403
+ for i, param_name in enumerate(param_names, 1):
404
+ # Replace :param with %s
405
+ prepared_sql = prepared_sql.replace(f':{param_name}', '%s', 1)
406
+
407
+ # Build parameter list in correct order
408
+ prepared_params = [params.get(name) for name in param_names]
409
+
410
+ elif db_type in ['mysql', 'mariadb']:
411
+ # MySQL uses %s placeholders
412
+ prepared_sql = sql
413
+ for param_name in param_names:
414
+ prepared_sql = prepared_sql.replace(f':{param_name}', '%s', 1)
415
+
416
+ # Build parameter list in correct order
417
+ prepared_params = [params.get(name) for name in param_names]
418
+
419
+ elif db_type == 'sqlite':
420
+ # SQLite uses ? placeholders
421
+ prepared_sql = sql
422
+ for param_name in param_names:
423
+ prepared_sql = prepared_sql.replace(f':{param_name}', '?', 1)
424
+
425
+ # Build parameter list in correct order
426
+ prepared_params = [params.get(name) for name in param_names]
427
+
428
+ else:
429
+ raise QueryExecutionError(f"Unsupported database type: {db_type}")
430
+
431
+ return prepared_sql, prepared_params
432
+
433
+ def close_connection(self, datasource_name: str):
434
+ """Close database connection"""
435
+ if datasource_name in self.connection_pool:
436
+ try:
437
+ self.connection_pool[datasource_name].close()
438
+ except:
439
+ pass
440
+ del self.connection_pool[datasource_name]
441
+
442
+ def close_all_connections(self):
443
+ """Close all database connections"""
444
+ for datasource_name in list(self.connection_pool.keys()):
445
+ self.close_connection(datasource_name)
446
+
447
+ # Phase D: Database Backend - Transaction Support
448
+
449
+ def begin_transaction(self, datasource_name: str = "default") -> Dict[str, Any]:
450
+ """
451
+ Begin a real database transaction.
452
+
453
+ Opens a DEDICATED connection with autocommit off and holds it in the
454
+ context. execute_query() routes to this connection while the
455
+ transaction is active and does not commit; commit/rollback here do the
456
+ real thing.
457
+
458
+ This used to return a dict and mark flags on it — no BEGIN, no COMMIT,
459
+ no ROLLBACK ever reached the database. Each q:query inside the block
460
+ opened its own pooled connection and autocommitted, so a q:transaction
461
+ was a no-op: a failed transfer left the debit committed and the credit
462
+ gone while the framework printed "rolled back". Reproduced with a
463
+ two-account transfer before fixing. See tests/integration/
464
+ test_transaction_atomicity.py.
465
+ """
466
+ config = self.get_datasource_config(datasource_name)
467
+ # A fresh connection, NOT the pooled one, so the open transaction never
468
+ # interferes with other queries and vice versa.
469
+ conn = self._open_raw_connection(config)
470
+ db_type = config['type']
471
+
472
+ # Turn autocommit off / open the transaction explicitly per driver.
473
+ try:
474
+ if db_type == 'sqlite':
475
+ # sqlite3 with isolation_level=None is autocommit; set a level
476
+ # so the driver opens a transaction on the first write.
477
+ conn.isolation_level = 'DEFERRED'
478
+ conn.execute('BEGIN')
479
+ elif db_type == 'postgresql':
480
+ conn.autocommit = False
481
+ elif db_type in ('mysql', 'mariadb'):
482
+ conn.begin()
483
+ except Exception as exc:
484
+ try:
485
+ conn.close()
486
+ except Exception:
487
+ pass
488
+ raise QueryExecutionError(f"could not begin transaction: {exc}")
489
+
490
+ tx = {
491
+ 'datasource': datasource_name,
492
+ 'active': True,
493
+ 'connection': conn,
494
+ 'db_type': db_type,
495
+ 'start_time': time.time(),
496
+ }
497
+ self._active_transactions[datasource_name] = tx
498
+ return tx
499
+
500
+ def commit_transaction(self, transaction_context: Dict[str, Any]) -> bool:
501
+ """Commit the real transaction and release its connection."""
502
+ return self._finish_transaction(transaction_context, commit=True)
503
+
504
+ def rollback_transaction(self, transaction_context: Dict[str, Any]) -> bool:
505
+ """Roll the real transaction back and release its connection."""
506
+ return self._finish_transaction(transaction_context, commit=False)
507
+
508
+ def _finish_transaction(self, tx: Dict[str, Any], commit: bool) -> bool:
509
+ conn = tx.get('connection')
510
+ ds = tx.get('datasource')
511
+ tx['active'] = False
512
+ tx['committed'] = commit
513
+ tx['rolled_back'] = not commit
514
+ # Stop routing new queries here regardless of what happens next.
515
+ if self._active_transactions.get(ds) is tx:
516
+ del self._active_transactions[ds]
517
+ if conn is None:
518
+ return True
519
+ try:
520
+ conn.commit() if commit else conn.rollback()
521
+ finally:
522
+ try:
523
+ conn.close()
524
+ except Exception:
525
+ pass
526
+ return True
527
+
528
+ def _active_transaction_for(self, datasource_name: str):
529
+ """The open transaction's connection for this datasource, or None."""
530
+ tx = self._active_transactions.get(datasource_name)
531
+ if tx and tx.get('active'):
532
+ return tx.get('connection')
533
+ return None
534
+
535
+ # Phase D: Query Caching
536
+
537
+ def __init__(self, admin_api_url: str = "http://localhost:8000", local_datasources: Dict[str, Any] = None):
538
+ self.admin_api_url = admin_api_url
539
+ self.local_datasources = local_datasources or {}
540
+ self.connection_pool: Dict[str, Any] = {}
541
+ # Phase D: Query cache with TTL
542
+ self.query_cache: Dict[str, Dict[str, Any]] = {} # cache_key -> {result, expires_at}
543
+ # datasource -> open transaction context (its dedicated connection)
544
+ self._active_transactions: Dict[str, Any] = {}
545
+
546
+ def _get_cache_key(self, sql: str, params: Dict[str, Any], datasource: str) -> str:
547
+ """Generate cache key for query"""
548
+ import hashlib
549
+ cache_str = f"{datasource}:{sql}:{str(sorted(params.items()))}"
550
+ return hashlib.md5(cache_str.encode()).hexdigest()
551
+
552
+ def _parse_cache_ttl(self, cache: str) -> int:
553
+ """
554
+ Parse cache TTL string like '5m', '1h', '30s'
555
+
556
+ Returns:
557
+ TTL in seconds
558
+ """
559
+ if not cache or cache.lower() == 'false':
560
+ return 0
561
+
562
+ if cache.lower() == 'true':
563
+ return 300 # Default 5 minutes
564
+
565
+ # Parse time units
566
+ cache = cache.lower().strip()
567
+ if cache.endswith('s'):
568
+ return int(cache[:-1])
569
+ elif cache.endswith('m'):
570
+ return int(cache[:-1]) * 60
571
+ elif cache.endswith('h'):
572
+ return int(cache[:-1]) * 3600
573
+ elif cache.endswith('d'):
574
+ return int(cache[:-1]) * 86400
575
+ else:
576
+ return int(cache) # Assume seconds
577
+
578
+ def get_cached_query(self, cache_key: str) -> Optional[QueryResult]:
579
+ """Get cached query result if not expired"""
580
+ if cache_key not in self.query_cache:
581
+ return None
582
+
583
+ cached = self.query_cache[cache_key]
584
+ if time.time() > cached['expires_at']:
585
+ # Expired - remove from cache
586
+ del self.query_cache[cache_key]
587
+ return None
588
+
589
+ # Mark result as cached
590
+ cached['result'].cached = True
591
+ return cached['result']
592
+
593
+ def cache_query_result(self, cache_key: str, result: QueryResult, ttl_seconds: int):
594
+ """Cache query result with TTL"""
595
+ self.query_cache[cache_key] = {
596
+ 'result': result,
597
+ 'expires_at': time.time() + ttl_seconds
598
+ }