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.
- quantum/__init__.py +1 -0
- quantum/cli/__init__.py +2 -0
- quantum/cli/admin.py +131 -0
- quantum/cli/check.py +223 -0
- quantum/cli/commands/__init__.py +15 -0
- quantum/cli/commands/build.py +414 -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 +548 -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 +565 -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 +2619 -0
- quantum/core/config_env.py +54 -0
- quantum/core/docs_links.py +8 -0
- quantum/core/expression_diagnostics.py +87 -0
- quantum/core/expression_stdlib.py +208 -0
- quantum/core/expressions.py +677 -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_import/src/__init__.py +0 -0
- quantum/core/features/data_import/src/ast_node.py +292 -0
- quantum/core/features/data_import/src/runtime.py +533 -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 +147 -0
- quantum/core/features/invocation/src/runtime.py +332 -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/query_validators.py +20 -0
- quantum/core/features/state_management/src/__init__.py +11 -0
- quantum/core/features/state_management/src/ast_node.py +121 -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 +1913 -0
- quantum/core/features/ui_engine/src/parser.py +1192 -0
- quantum/core/features/websocket/src/__init__.py +24 -0
- quantum/core/features/websocket/src/ast_node.py +247 -0
- quantum/core/guards.py +53 -0
- quantum/core/html_compat.py +299 -0
- quantum/core/parser.py +1551 -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 +71 -0
- quantum/core/parsers/control_flow/loop_parser.py +105 -0
- quantum/core/parsers/control_flow/set_parser.py +93 -0
- quantum/core/parsers/data/__init__.py +12 -0
- quantum/core/parsers/data/data_parser.py +229 -0
- quantum/core/parsers/data/invoke_parser.py +133 -0
- quantum/core/parsers/data/query_parser.py +213 -0
- quantum/core/parsers/data/transaction_parser.py +86 -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 +79 -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 +98 -0
- quantum/core/parsers/functions/param_parser.py +73 -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/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 +164 -0
- quantum/core/xml_lines.py +81 -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 +25 -0
- quantum/runtime/action_handler.py +532 -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/ast_cache.py +506 -0
- quantum/runtime/auth_service.py +267 -0
- quantum/runtime/component.py +1048 -0
- quantum/runtime/component_composer.py +133 -0
- quantum/runtime/component_resolver.py +174 -0
- quantum/runtime/conditions.py +54 -0
- quantum/runtime/database_service.py +595 -0
- quantum/runtime/db_schema.py +100 -0
- quantum/runtime/dev_panel.py +178 -0
- quantum/runtime/email_service.py +162 -0
- quantum/runtime/error_handler.py +295 -0
- quantum/runtime/execution_context.py +283 -0
- quantum/runtime/executor_registry.py +178 -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 +264 -0
- quantum/runtime/executors/control_flow/__init__.py +12 -0
- quantum/runtime/executors/control_flow/if_executor.py +98 -0
- quantum/runtime/executors/control_flow/loop_executor.py +308 -0
- quantum/runtime/executors/control_flow/redirect_executor.py +43 -0
- quantum/runtime/executors/control_flow/set_executor.py +464 -0
- quantum/runtime/executors/data/__init__.py +12 -0
- quantum/runtime/executors/data/data_executor.py +156 -0
- quantum/runtime/executors/data/invoke_executor.py +228 -0
- quantum/runtime/executors/data/query_executor.py +290 -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 +546 -0
- quantum/runtime/llm_cache.py +100 -0
- quantum/runtime/llm_providers.py +704 -0
- quantum/runtime/llm_service.py +287 -0
- quantum/runtime/local_server.py +28 -0
- quantum/runtime/logging_setup.py +141 -0
- quantum/runtime/message_broker.py +364 -0
- quantum/runtime/message_queue_service.py +571 -0
- quantum/runtime/param_validation.py +218 -0
- quantum/runtime/pypy_compat.py +317 -0
- quantum/runtime/python_bridge.py +698 -0
- quantum/runtime/query_validators.py +304 -0
- quantum/runtime/renderer.py +734 -0
- quantum/runtime/service_container.py +470 -0
- quantum/runtime/table_params.py +163 -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 +222 -0
- quantum/runtime/ui_console.py +405 -0
- quantum/runtime/ui_desktop.py +56 -0
- quantum/runtime/ui_form_rules.py +193 -0
- quantum/runtime/ui_html_adapter.py +2567 -0
- quantum/runtime/ui_html_templates.py +2106 -0
- quantum/runtime/ui_mobile_adapter.py +1832 -0
- quantum/runtime/ui_mobile_templates.py +1003 -0
- quantum/runtime/ui_page_renderer.py +392 -0
- quantum/runtime/ui_pager.py +73 -0
- quantum/runtime/ui_table_edit.py +139 -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/ui_view.py +221 -0
- quantum/runtime/validators.py +256 -0
- quantum/runtime/web_server.py +2131 -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/services.py +109 -0
- quantum/utils/__init__.py +1 -0
- quantum_admin/__init__.py +4 -0
- quantum_admin/assets/quantum-admin-connectors.js +123 -0
- quantum_admin/assets/quantum-admin.css +2058 -0
- quantum_admin/assets/quantum-source-highlight.js +106 -0
- quantum_admin/backend/__init__.py +4 -0
- quantum_admin/backend/audit_service.py +346 -0
- quantum_admin/backend/auth_service.py +394 -0
- quantum_admin/backend/component_discovery.py +766 -0
- quantum_admin/backend/config_generator.py +586 -0
- quantum_admin/backend/connector_service.py +1016 -0
- quantum_admin/backend/crud.py +557 -0
- quantum_admin/backend/database.py +226 -0
- quantum_admin/backend/db_setup_service.py +193 -0
- quantum_admin/backend/deploy_service.py +698 -0
- quantum_admin/backend/docker_service.py +422 -0
- quantum_admin/backend/environment_service.py +418 -0
- quantum_admin/backend/git_service.py +370 -0
- quantum_admin/backend/health_service.py +390 -0
- quantum_admin/backend/job_service.py +347 -0
- quantum_admin/backend/log_service.py +331 -0
- quantum_admin/backend/main.py +14426 -0
- quantum_admin/backend/models.py +986 -0
- quantum_admin/backend/pipeline_service.py +812 -0
- quantum_admin/backend/resource_manager.py +1118 -0
- quantum_admin/backend/schemas.py +419 -0
- quantum_admin/backend/secret_manager.py +277 -0
- quantum_admin/backend/settings_service.py +488 -0
- quantum_admin/backend/template_service.py +710 -0
- quantum_admin/backend/test_execution_service.py +364 -0
- quantum_admin/backend/test_generator.py +782 -0
- quantum_admin/backend/webhook_service.py +454 -0
- quantum_admin/backend/websocket_manager.py +360 -0
- quantum_admin/components/admin/_layout/AdminShell.q +121 -0
- quantum_admin/components/admin/agents.q +107 -0
- quantum_admin/components/admin/app/[name].q +558 -0
- quantum_admin/components/admin/applications.q +160 -0
- quantum_admin/components/admin/component/[...path].q +205 -0
- quantum_admin/components/admin/components.q +59 -0
- quantum_admin/components/admin/connectors.q +283 -0
- quantum_admin/components/admin/dashboard.q +86 -0
- quantum_admin/components/admin/database.q +95 -0
- quantum_admin/components/admin/features.q +69 -0
- quantum_admin/components/admin/index.q +12 -0
- quantum_admin/components/admin/jobs.q +75 -0
- quantum_admin/components/admin/login.q +65 -0
- quantum_admin/components/admin/logout.q +11 -0
- quantum_admin/components/admin/settings.q +174 -0
- quantum_admin/components/admin/source.q +45 -0
- quantum_admin/components/admin/tests.q +73 -0
- quantum_admin/run.py +13 -0
- quantum_admin/services/__init__.py +16 -0
- quantum_admin/services/_base.py +84 -0
- quantum_admin/services/_component_test_generator.py +205 -0
- quantum_admin/services/_yaml_editor.py +82 -0
- quantum_admin/services/apps.py +330 -0
- quantum_admin/services/auth.py +61 -0
- quantum_admin/services/components.py +210 -0
- quantum_admin/services/connectors.py +164 -0
- quantum_admin/services/projects.py +189 -0
- quantum_admin/services/repository.py +223 -0
- quantum_admin/services/settings.py +150 -0
- quantum_admin/services/yaml_import.py +118 -0
- quantum_framework-0.18.0.dist-info/METADATA +281 -0
- quantum_framework-0.18.0.dist-info/RECORD +333 -0
- quantum_framework-0.18.0.dist-info/WHEEL +5 -0
- quantum_framework-0.18.0.dist-info/entry_points.txt +2 -0
- quantum_framework-0.18.0.dist-info/licenses/LICENSE +21 -0
- 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 []]
|