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