hexdag 0.5.0.dev1__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.
- hexdag/__init__.py +116 -0
- hexdag/__main__.py +30 -0
- hexdag/adapters/executors/__init__.py +5 -0
- hexdag/adapters/executors/local_executor.py +316 -0
- hexdag/builtin/__init__.py +6 -0
- hexdag/builtin/adapters/__init__.py +51 -0
- hexdag/builtin/adapters/anthropic/__init__.py +5 -0
- hexdag/builtin/adapters/anthropic/anthropic_adapter.py +151 -0
- hexdag/builtin/adapters/database/__init__.py +6 -0
- hexdag/builtin/adapters/database/csv/csv_adapter.py +249 -0
- hexdag/builtin/adapters/database/pgvector/__init__.py +5 -0
- hexdag/builtin/adapters/database/pgvector/pgvector_adapter.py +478 -0
- hexdag/builtin/adapters/database/sqlalchemy/sqlalchemy_adapter.py +252 -0
- hexdag/builtin/adapters/database/sqlite/__init__.py +5 -0
- hexdag/builtin/adapters/database/sqlite/sqlite_adapter.py +410 -0
- hexdag/builtin/adapters/local/README.md +59 -0
- hexdag/builtin/adapters/local/__init__.py +7 -0
- hexdag/builtin/adapters/local/local_observer_manager.py +696 -0
- hexdag/builtin/adapters/memory/__init__.py +47 -0
- hexdag/builtin/adapters/memory/file_memory_adapter.py +297 -0
- hexdag/builtin/adapters/memory/in_memory_memory.py +216 -0
- hexdag/builtin/adapters/memory/schemas.py +57 -0
- hexdag/builtin/adapters/memory/session_memory.py +178 -0
- hexdag/builtin/adapters/memory/sqlite_memory_adapter.py +215 -0
- hexdag/builtin/adapters/memory/state_memory.py +280 -0
- hexdag/builtin/adapters/mock/README.md +89 -0
- hexdag/builtin/adapters/mock/__init__.py +15 -0
- hexdag/builtin/adapters/mock/hexdag.toml +50 -0
- hexdag/builtin/adapters/mock/mock_database.py +225 -0
- hexdag/builtin/adapters/mock/mock_embedding.py +223 -0
- hexdag/builtin/adapters/mock/mock_llm.py +177 -0
- hexdag/builtin/adapters/mock/mock_tool_adapter.py +192 -0
- hexdag/builtin/adapters/mock/mock_tool_router.py +232 -0
- hexdag/builtin/adapters/openai/__init__.py +5 -0
- hexdag/builtin/adapters/openai/openai_adapter.py +634 -0
- hexdag/builtin/adapters/secret/__init__.py +7 -0
- hexdag/builtin/adapters/secret/local_secret_adapter.py +248 -0
- hexdag/builtin/adapters/unified_tool_router.py +280 -0
- hexdag/builtin/macros/__init__.py +17 -0
- hexdag/builtin/macros/conversation_agent.py +390 -0
- hexdag/builtin/macros/llm_macro.py +151 -0
- hexdag/builtin/macros/reasoning_agent.py +423 -0
- hexdag/builtin/macros/tool_macro.py +380 -0
- hexdag/builtin/nodes/__init__.py +38 -0
- hexdag/builtin/nodes/_discovery.py +123 -0
- hexdag/builtin/nodes/agent_node.py +696 -0
- hexdag/builtin/nodes/base_node_factory.py +242 -0
- hexdag/builtin/nodes/composite_node.py +926 -0
- hexdag/builtin/nodes/data_node.py +201 -0
- hexdag/builtin/nodes/expression_node.py +487 -0
- hexdag/builtin/nodes/function_node.py +454 -0
- hexdag/builtin/nodes/llm_node.py +491 -0
- hexdag/builtin/nodes/loop_node.py +920 -0
- hexdag/builtin/nodes/mapped_input.py +518 -0
- hexdag/builtin/nodes/port_call_node.py +269 -0
- hexdag/builtin/nodes/tool_call_node.py +195 -0
- hexdag/builtin/nodes/tool_utils.py +390 -0
- hexdag/builtin/prompts/__init__.py +68 -0
- hexdag/builtin/prompts/base.py +422 -0
- hexdag/builtin/prompts/chat_prompts.py +303 -0
- hexdag/builtin/prompts/error_correction_prompts.py +320 -0
- hexdag/builtin/prompts/tool_prompts.py +160 -0
- hexdag/builtin/tools/builtin_tools.py +84 -0
- hexdag/builtin/tools/database_tools.py +164 -0
- hexdag/cli/__init__.py +17 -0
- hexdag/cli/__main__.py +7 -0
- hexdag/cli/commands/__init__.py +27 -0
- hexdag/cli/commands/build_cmd.py +812 -0
- hexdag/cli/commands/create_cmd.py +208 -0
- hexdag/cli/commands/docs_cmd.py +293 -0
- hexdag/cli/commands/generate_types_cmd.py +252 -0
- hexdag/cli/commands/init_cmd.py +188 -0
- hexdag/cli/commands/pipeline_cmd.py +494 -0
- hexdag/cli/commands/plugin_dev_cmd.py +529 -0
- hexdag/cli/commands/plugins_cmd.py +441 -0
- hexdag/cli/commands/studio_cmd.py +101 -0
- hexdag/cli/commands/validate_cmd.py +221 -0
- hexdag/cli/main.py +84 -0
- hexdag/core/__init__.py +83 -0
- hexdag/core/config/__init__.py +20 -0
- hexdag/core/config/loader.py +479 -0
- hexdag/core/config/models.py +150 -0
- hexdag/core/configurable.py +294 -0
- hexdag/core/context/__init__.py +37 -0
- hexdag/core/context/execution_context.py +378 -0
- hexdag/core/docs/__init__.py +26 -0
- hexdag/core/docs/extractors.py +678 -0
- hexdag/core/docs/generators.py +890 -0
- hexdag/core/docs/models.py +120 -0
- hexdag/core/domain/__init__.py +10 -0
- hexdag/core/domain/dag.py +1225 -0
- hexdag/core/exceptions.py +234 -0
- hexdag/core/expression_parser.py +569 -0
- hexdag/core/logging.py +449 -0
- hexdag/core/models/__init__.py +17 -0
- hexdag/core/models/base.py +138 -0
- hexdag/core/orchestration/__init__.py +46 -0
- hexdag/core/orchestration/body_executor.py +481 -0
- hexdag/core/orchestration/components/__init__.py +97 -0
- hexdag/core/orchestration/components/adapter_lifecycle_manager.py +113 -0
- hexdag/core/orchestration/components/checkpoint_manager.py +134 -0
- hexdag/core/orchestration/components/execution_coordinator.py +360 -0
- hexdag/core/orchestration/components/health_check_manager.py +176 -0
- hexdag/core/orchestration/components/input_mapper.py +143 -0
- hexdag/core/orchestration/components/lifecycle_manager.py +583 -0
- hexdag/core/orchestration/components/node_executor.py +377 -0
- hexdag/core/orchestration/components/secret_manager.py +202 -0
- hexdag/core/orchestration/components/wave_executor.py +158 -0
- hexdag/core/orchestration/constants.py +17 -0
- hexdag/core/orchestration/events/README.md +312 -0
- hexdag/core/orchestration/events/__init__.py +104 -0
- hexdag/core/orchestration/events/batching.py +330 -0
- hexdag/core/orchestration/events/decorators.py +139 -0
- hexdag/core/orchestration/events/events.py +573 -0
- hexdag/core/orchestration/events/observers/__init__.py +30 -0
- hexdag/core/orchestration/events/observers/core_observers.py +690 -0
- hexdag/core/orchestration/events/observers/models.py +111 -0
- hexdag/core/orchestration/events/taxonomy.py +269 -0
- hexdag/core/orchestration/hook_context.py +237 -0
- hexdag/core/orchestration/hooks.py +437 -0
- hexdag/core/orchestration/models.py +418 -0
- hexdag/core/orchestration/orchestrator.py +910 -0
- hexdag/core/orchestration/orchestrator_factory.py +275 -0
- hexdag/core/orchestration/port_wrappers.py +327 -0
- hexdag/core/orchestration/prompt/__init__.py +32 -0
- hexdag/core/orchestration/prompt/template.py +332 -0
- hexdag/core/pipeline_builder/__init__.py +21 -0
- hexdag/core/pipeline_builder/component_instantiator.py +386 -0
- hexdag/core/pipeline_builder/include_tag.py +265 -0
- hexdag/core/pipeline_builder/pipeline_config.py +133 -0
- hexdag/core/pipeline_builder/py_tag.py +223 -0
- hexdag/core/pipeline_builder/tag_discovery.py +268 -0
- hexdag/core/pipeline_builder/yaml_builder.py +1196 -0
- hexdag/core/pipeline_builder/yaml_validator.py +569 -0
- hexdag/core/ports/__init__.py +65 -0
- hexdag/core/ports/api_call.py +133 -0
- hexdag/core/ports/database.py +489 -0
- hexdag/core/ports/embedding.py +215 -0
- hexdag/core/ports/executor.py +237 -0
- hexdag/core/ports/file_storage.py +117 -0
- hexdag/core/ports/healthcheck.py +87 -0
- hexdag/core/ports/llm.py +551 -0
- hexdag/core/ports/memory.py +70 -0
- hexdag/core/ports/observer_manager.py +130 -0
- hexdag/core/ports/secret.py +145 -0
- hexdag/core/ports/tool_router.py +94 -0
- hexdag/core/ports_builder.py +623 -0
- hexdag/core/protocols.py +273 -0
- hexdag/core/resolver.py +304 -0
- hexdag/core/schema/__init__.py +9 -0
- hexdag/core/schema/generator.py +742 -0
- hexdag/core/secrets.py +242 -0
- hexdag/core/types.py +413 -0
- hexdag/core/utils/async_warnings.py +206 -0
- hexdag/core/utils/schema_conversion.py +78 -0
- hexdag/core/utils/sql_validation.py +86 -0
- hexdag/core/validation/secure_json.py +148 -0
- hexdag/core/yaml_macro.py +517 -0
- hexdag/mcp_server.py +3120 -0
- hexdag/studio/__init__.py +10 -0
- hexdag/studio/build_ui.py +92 -0
- hexdag/studio/server/__init__.py +1 -0
- hexdag/studio/server/main.py +100 -0
- hexdag/studio/server/routes/__init__.py +9 -0
- hexdag/studio/server/routes/execute.py +208 -0
- hexdag/studio/server/routes/export.py +558 -0
- hexdag/studio/server/routes/files.py +207 -0
- hexdag/studio/server/routes/plugins.py +419 -0
- hexdag/studio/server/routes/validate.py +220 -0
- hexdag/studio/ui/index.html +13 -0
- hexdag/studio/ui/package-lock.json +2992 -0
- hexdag/studio/ui/package.json +31 -0
- hexdag/studio/ui/postcss.config.js +6 -0
- hexdag/studio/ui/public/hexdag.svg +5 -0
- hexdag/studio/ui/src/App.tsx +251 -0
- hexdag/studio/ui/src/components/Canvas.tsx +408 -0
- hexdag/studio/ui/src/components/ContextMenu.tsx +187 -0
- hexdag/studio/ui/src/components/FileBrowser.tsx +123 -0
- hexdag/studio/ui/src/components/Header.tsx +181 -0
- hexdag/studio/ui/src/components/HexdagNode.tsx +193 -0
- hexdag/studio/ui/src/components/NodeInspector.tsx +512 -0
- hexdag/studio/ui/src/components/NodePalette.tsx +262 -0
- hexdag/studio/ui/src/components/NodePortsSection.tsx +403 -0
- hexdag/studio/ui/src/components/PluginManager.tsx +347 -0
- hexdag/studio/ui/src/components/PortsEditor.tsx +481 -0
- hexdag/studio/ui/src/components/PythonEditor.tsx +195 -0
- hexdag/studio/ui/src/components/ValidationPanel.tsx +105 -0
- hexdag/studio/ui/src/components/YamlEditor.tsx +196 -0
- hexdag/studio/ui/src/components/index.ts +8 -0
- hexdag/studio/ui/src/index.css +92 -0
- hexdag/studio/ui/src/main.tsx +10 -0
- hexdag/studio/ui/src/types/index.ts +123 -0
- hexdag/studio/ui/src/vite-env.d.ts +1 -0
- hexdag/studio/ui/tailwind.config.js +29 -0
- hexdag/studio/ui/tsconfig.json +37 -0
- hexdag/studio/ui/tsconfig.node.json +13 -0
- hexdag/studio/ui/vite.config.ts +35 -0
- hexdag/visualization/__init__.py +69 -0
- hexdag/visualization/dag_visualizer.py +1020 -0
- hexdag-0.5.0.dev1.dist-info/METADATA +369 -0
- hexdag-0.5.0.dev1.dist-info/RECORD +261 -0
- hexdag-0.5.0.dev1.dist-info/WHEEL +4 -0
- hexdag-0.5.0.dev1.dist-info/entry_points.txt +4 -0
- hexdag-0.5.0.dev1.dist-info/licenses/LICENSE +190 -0
- hexdag_plugins/.gitignore +43 -0
- hexdag_plugins/README.md +73 -0
- hexdag_plugins/__init__.py +1 -0
- hexdag_plugins/azure/LICENSE +21 -0
- hexdag_plugins/azure/README.md +414 -0
- hexdag_plugins/azure/__init__.py +21 -0
- hexdag_plugins/azure/azure_blob_adapter.py +450 -0
- hexdag_plugins/azure/azure_cosmos_adapter.py +383 -0
- hexdag_plugins/azure/azure_keyvault_adapter.py +314 -0
- hexdag_plugins/azure/azure_openai_adapter.py +415 -0
- hexdag_plugins/azure/pyproject.toml +107 -0
- hexdag_plugins/azure/tests/__init__.py +1 -0
- hexdag_plugins/azure/tests/test_azure_blob_adapter.py +350 -0
- hexdag_plugins/azure/tests/test_azure_cosmos_adapter.py +323 -0
- hexdag_plugins/azure/tests/test_azure_keyvault_adapter.py +330 -0
- hexdag_plugins/azure/tests/test_azure_openai_adapter.py +329 -0
- hexdag_plugins/hexdag_etl/README.md +168 -0
- hexdag_plugins/hexdag_etl/__init__.py +53 -0
- hexdag_plugins/hexdag_etl/examples/01_simple_pandas_transform.py +270 -0
- hexdag_plugins/hexdag_etl/examples/02_simple_pandas_only.py +149 -0
- hexdag_plugins/hexdag_etl/examples/03_file_io_pipeline.py +109 -0
- hexdag_plugins/hexdag_etl/examples/test_pandas_transform.py +84 -0
- hexdag_plugins/hexdag_etl/hexdag.toml +25 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/__init__.py +48 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/__init__.py +13 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/api_extract.py +230 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/base_node_factory.py +181 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/file_io.py +415 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/outlook.py +492 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/pandas_transform.py +563 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/sql_extract_load.py +112 -0
- hexdag_plugins/hexdag_etl/pyproject.toml +82 -0
- hexdag_plugins/hexdag_etl/test_transform.py +54 -0
- hexdag_plugins/hexdag_etl/tests/test_plugin_integration.py +62 -0
- hexdag_plugins/mysql_adapter/LICENSE +21 -0
- hexdag_plugins/mysql_adapter/README.md +224 -0
- hexdag_plugins/mysql_adapter/__init__.py +6 -0
- hexdag_plugins/mysql_adapter/mysql_adapter.py +408 -0
- hexdag_plugins/mysql_adapter/pyproject.toml +93 -0
- hexdag_plugins/mysql_adapter/tests/test_mysql_adapter.py +259 -0
- hexdag_plugins/storage/README.md +184 -0
- hexdag_plugins/storage/__init__.py +19 -0
- hexdag_plugins/storage/file/__init__.py +5 -0
- hexdag_plugins/storage/file/local.py +325 -0
- hexdag_plugins/storage/ports/__init__.py +5 -0
- hexdag_plugins/storage/ports/vector_store.py +236 -0
- hexdag_plugins/storage/sql/__init__.py +7 -0
- hexdag_plugins/storage/sql/base.py +187 -0
- hexdag_plugins/storage/sql/mysql.py +27 -0
- hexdag_plugins/storage/sql/postgresql.py +27 -0
- hexdag_plugins/storage/tests/__init__.py +1 -0
- hexdag_plugins/storage/tests/test_local_file_storage.py +161 -0
- hexdag_plugins/storage/tests/test_sql_adapters.py +212 -0
- hexdag_plugins/storage/vector/__init__.py +7 -0
- hexdag_plugins/storage/vector/chromadb.py +223 -0
- hexdag_plugins/storage/vector/in_memory.py +285 -0
- hexdag_plugins/storage/vector/pgvector.py +502 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Base SQL adapter using SQLAlchemy for connection pooling and async operations."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import SecretStr
|
|
6
|
+
from sqlalchemy import text
|
|
7
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine
|
|
8
|
+
|
|
9
|
+
from hexdag.core import AdapterConfig, ConfigurableAdapter, SecretField
|
|
10
|
+
from hexdag.core.ports.healthcheck import HealthStatus
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DatabaseConfig(AdapterConfig):
|
|
14
|
+
"""Base configuration for SQL database adapters.
|
|
15
|
+
|
|
16
|
+
Attributes
|
|
17
|
+
----------
|
|
18
|
+
connection_string : SecretStr
|
|
19
|
+
Database connection string (e.g., "postgresql+asyncpg://user:pass@host/db")
|
|
20
|
+
pool_size : int
|
|
21
|
+
Number of permanent connections in the pool (default: 5)
|
|
22
|
+
max_overflow : int
|
|
23
|
+
Maximum number of connections beyond pool_size (default: 10)
|
|
24
|
+
pool_timeout : float
|
|
25
|
+
Timeout in seconds when getting connection from pool (default: 30.0)
|
|
26
|
+
pool_recycle : int
|
|
27
|
+
Recycle connections after this many seconds (default: 3600)
|
|
28
|
+
pool_pre_ping : bool
|
|
29
|
+
Test connections before using them (default: True)
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
connection_string: SecretStr = SecretField(
|
|
33
|
+
env_var="DATABASE_URL",
|
|
34
|
+
description="Database connection string",
|
|
35
|
+
)
|
|
36
|
+
pool_size: int = 5
|
|
37
|
+
max_overflow: int = 10
|
|
38
|
+
pool_timeout: float = 30.0
|
|
39
|
+
pool_recycle: int = 3600
|
|
40
|
+
pool_pre_ping: bool = True
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SQLAdapter(ConfigurableAdapter):
|
|
44
|
+
"""Base SQL adapter with SQLAlchemy connection pooling.
|
|
45
|
+
|
|
46
|
+
This base class provides common functionality for all SQL databases:
|
|
47
|
+
- Async connection pooling
|
|
48
|
+
- Health checks
|
|
49
|
+
- Basic query execution
|
|
50
|
+
- Proper resource cleanup
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
Config = DatabaseConfig
|
|
54
|
+
|
|
55
|
+
def __init__(self, **kwargs):
|
|
56
|
+
"""Initialize SQL adapter with configuration."""
|
|
57
|
+
super().__init__(**kwargs)
|
|
58
|
+
self._engine: AsyncEngine | None = None
|
|
59
|
+
|
|
60
|
+
async def asetup(self):
|
|
61
|
+
"""Initialize SQLAlchemy async engine with connection pool."""
|
|
62
|
+
self._engine = create_async_engine(
|
|
63
|
+
self.config.connection_string.get_secret_value(),
|
|
64
|
+
pool_size=self.config.pool_size,
|
|
65
|
+
max_overflow=self.config.max_overflow,
|
|
66
|
+
pool_timeout=self.config.pool_timeout,
|
|
67
|
+
pool_recycle=self.config.pool_recycle,
|
|
68
|
+
pool_pre_ping=self.config.pool_pre_ping,
|
|
69
|
+
echo=False,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
async def aclose(self):
|
|
73
|
+
"""Close database connection pool."""
|
|
74
|
+
if self._engine:
|
|
75
|
+
await self._engine.dispose()
|
|
76
|
+
self._engine = None
|
|
77
|
+
|
|
78
|
+
async def aexecute(self, query: str, params: dict[str, Any] | None = None) -> Any:
|
|
79
|
+
"""Execute a SQL query (INSERT, UPDATE, DELETE, DDL).
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
query: SQL query string
|
|
83
|
+
params: Query parameters for parameterized queries
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Result of the execution
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
RuntimeError: If adapter not set up
|
|
90
|
+
"""
|
|
91
|
+
if not self._engine:
|
|
92
|
+
msg = "Adapter not set up. Call asetup() first."
|
|
93
|
+
raise RuntimeError(msg)
|
|
94
|
+
|
|
95
|
+
async with AsyncSession(self._engine) as session:
|
|
96
|
+
result = await session.execute(text(query), params or {})
|
|
97
|
+
await session.commit()
|
|
98
|
+
return result
|
|
99
|
+
|
|
100
|
+
async def afetch_one(
|
|
101
|
+
self, query: str, params: dict[str, Any] | None = None
|
|
102
|
+
) -> dict[str, Any] | None:
|
|
103
|
+
"""Fetch a single row from the database.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
query: SQL query string
|
|
107
|
+
params: Query parameters for parameterized queries
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Single row as dictionary or None if no results
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
RuntimeError: If adapter not set up
|
|
114
|
+
"""
|
|
115
|
+
if not self._engine:
|
|
116
|
+
msg = "Adapter not set up. Call asetup() first."
|
|
117
|
+
raise RuntimeError(msg)
|
|
118
|
+
|
|
119
|
+
async with AsyncSession(self._engine) as session:
|
|
120
|
+
result = await session.execute(text(query), params or {})
|
|
121
|
+
row = result.fetchone()
|
|
122
|
+
return dict(row._mapping) if row else None
|
|
123
|
+
|
|
124
|
+
async def afetch_all(
|
|
125
|
+
self, query: str, params: dict[str, Any] | None = None
|
|
126
|
+
) -> list[dict[str, Any]]:
|
|
127
|
+
"""Fetch all rows from the database.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
query: SQL query string
|
|
131
|
+
params: Query parameters for parameterized queries
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
List of rows as dictionaries
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
RuntimeError: If adapter not set up
|
|
138
|
+
"""
|
|
139
|
+
if not self._engine:
|
|
140
|
+
msg = "Adapter not set up. Call asetup() first."
|
|
141
|
+
raise RuntimeError(msg)
|
|
142
|
+
|
|
143
|
+
async with AsyncSession(self._engine) as session:
|
|
144
|
+
result = await session.execute(text(query), params or {})
|
|
145
|
+
rows = result.fetchall()
|
|
146
|
+
return [dict(row._mapping) for row in rows]
|
|
147
|
+
|
|
148
|
+
async def ahealth_check(self) -> HealthStatus:
|
|
149
|
+
"""Check database connection health.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
HealthStatus with database availability and connection pool info
|
|
153
|
+
"""
|
|
154
|
+
if not self._engine:
|
|
155
|
+
return HealthStatus(
|
|
156
|
+
status="unhealthy",
|
|
157
|
+
adapter_name=self.__class__.__name__,
|
|
158
|
+
port_name="database",
|
|
159
|
+
details={"message": "Database not initialized"},
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
# Test query
|
|
164
|
+
async with AsyncSession(self._engine) as session:
|
|
165
|
+
await session.execute(text("SELECT 1"))
|
|
166
|
+
|
|
167
|
+
# Get pool statistics
|
|
168
|
+
pool = self._engine.pool
|
|
169
|
+
return HealthStatus(
|
|
170
|
+
status="healthy",
|
|
171
|
+
adapter_name=self.__class__.__name__,
|
|
172
|
+
port_name="database",
|
|
173
|
+
details={
|
|
174
|
+
"pool_size": pool.size(),
|
|
175
|
+
"checked_in": pool.checkedin(),
|
|
176
|
+
"checked_out": pool.checkedout(),
|
|
177
|
+
"overflow": pool.overflow(),
|
|
178
|
+
},
|
|
179
|
+
)
|
|
180
|
+
except Exception as e:
|
|
181
|
+
return HealthStatus(
|
|
182
|
+
status="unhealthy",
|
|
183
|
+
adapter_name=self.__class__.__name__,
|
|
184
|
+
port_name="database",
|
|
185
|
+
error=e,
|
|
186
|
+
details={"error": str(e)},
|
|
187
|
+
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""MySQL adapter using SQLAlchemy with aiomysql driver."""
|
|
2
|
+
|
|
3
|
+
from hexdag.core.registry.decorators import adapter
|
|
4
|
+
|
|
5
|
+
from .base import DatabaseConfig, SQLAdapter
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@adapter("database", name="mysql", namespace="storage")
|
|
9
|
+
class MySQLAdapter(SQLAdapter):
|
|
10
|
+
"""MySQL database adapter with async connection pooling.
|
|
11
|
+
|
|
12
|
+
Uses SQLAlchemy with aiomysql driver for async MySQL operations.
|
|
13
|
+
Provides connection pooling, health checks, and standard SQL operations.
|
|
14
|
+
|
|
15
|
+
Example connection string:
|
|
16
|
+
mysql+aiomysql://user:password@host:port/database
|
|
17
|
+
|
|
18
|
+
Configuration:
|
|
19
|
+
- connection_string: MySQL connection URL (env: DATABASE_URL)
|
|
20
|
+
- pool_size: Number of permanent connections (default: 5)
|
|
21
|
+
- max_overflow: Extra connections beyond pool_size (default: 10)
|
|
22
|
+
- pool_timeout: Timeout for getting connection (default: 30.0)
|
|
23
|
+
- pool_recycle: Recycle connections after seconds (default: 3600)
|
|
24
|
+
- pool_pre_ping: Test connections before use (default: True)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
Config = DatabaseConfig
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""PostgreSQL adapter using SQLAlchemy with asyncpg driver."""
|
|
2
|
+
|
|
3
|
+
from hexdag.core.registry.decorators import adapter
|
|
4
|
+
|
|
5
|
+
from .base import DatabaseConfig, SQLAdapter
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@adapter("database", name="postgresql", namespace="storage")
|
|
9
|
+
class PostgreSQLAdapter(SQLAdapter):
|
|
10
|
+
"""PostgreSQL database adapter with async connection pooling.
|
|
11
|
+
|
|
12
|
+
Uses SQLAlchemy with asyncpg driver for async PostgreSQL operations.
|
|
13
|
+
Provides connection pooling, health checks, and standard SQL operations.
|
|
14
|
+
|
|
15
|
+
Example connection string:
|
|
16
|
+
postgresql+asyncpg://user:password@host:port/database
|
|
17
|
+
|
|
18
|
+
Configuration:
|
|
19
|
+
- connection_string: PostgreSQL connection URL (env: DATABASE_URL)
|
|
20
|
+
- pool_size: Number of permanent connections (default: 5)
|
|
21
|
+
- max_overflow: Extra connections beyond pool_size (default: 10)
|
|
22
|
+
- pool_timeout: Timeout for getting connection (default: 30.0)
|
|
23
|
+
- pool_recycle: Recycle connections after seconds (default: 3600)
|
|
24
|
+
- pool_pre_ping: Test connections before use (default: True)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
Config = DatabaseConfig
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Tests for hexdag-storage."""
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Tests for local file storage adapter."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from hexdag_plugins.storage.file import LocalFileStorage
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestLocalFileStorage:
|
|
9
|
+
"""Test suite for LocalFileStorage adapter."""
|
|
10
|
+
|
|
11
|
+
@pytest.fixture
|
|
12
|
+
async def storage(self, tmp_path):
|
|
13
|
+
"""Create a test storage instance."""
|
|
14
|
+
storage = LocalFileStorage(base_path=str(tmp_path))
|
|
15
|
+
yield storage
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def test_file(self, tmp_path):
|
|
19
|
+
"""Create a test file."""
|
|
20
|
+
test_file = tmp_path / "test.txt"
|
|
21
|
+
test_file.write_text("Hello, World!")
|
|
22
|
+
return test_file
|
|
23
|
+
|
|
24
|
+
@pytest.mark.asyncio
|
|
25
|
+
async def test_initialization(self, tmp_path):
|
|
26
|
+
"""Test storage initialization."""
|
|
27
|
+
storage = LocalFileStorage(base_path=str(tmp_path))
|
|
28
|
+
assert storage._base_path == tmp_path
|
|
29
|
+
assert tmp_path.exists()
|
|
30
|
+
|
|
31
|
+
@pytest.mark.asyncio
|
|
32
|
+
async def test_upload(self, storage, test_file, tmp_path):
|
|
33
|
+
"""Test file upload."""
|
|
34
|
+
result = await storage.aupload(str(test_file), "docs/test.txt")
|
|
35
|
+
|
|
36
|
+
assert result["uploaded"] is True
|
|
37
|
+
assert result["remote_path"] == "docs/test.txt"
|
|
38
|
+
assert (tmp_path / "docs/test.txt").exists()
|
|
39
|
+
|
|
40
|
+
@pytest.mark.asyncio
|
|
41
|
+
async def test_download(self, storage, test_file, tmp_path):
|
|
42
|
+
"""Test file download."""
|
|
43
|
+
# First upload a file
|
|
44
|
+
await storage.aupload(str(test_file), "docs/test.txt")
|
|
45
|
+
|
|
46
|
+
# Download to a different location
|
|
47
|
+
download_path = tmp_path / "downloaded.txt"
|
|
48
|
+
result = await storage.adownload("docs/test.txt", str(download_path))
|
|
49
|
+
|
|
50
|
+
assert result["downloaded"] is True
|
|
51
|
+
assert download_path.exists()
|
|
52
|
+
assert download_path.read_text() == "Hello, World!"
|
|
53
|
+
|
|
54
|
+
@pytest.mark.asyncio
|
|
55
|
+
async def test_exists(self, storage, test_file):
|
|
56
|
+
"""Test file existence check."""
|
|
57
|
+
# File doesn't exist yet
|
|
58
|
+
assert await storage.aexists("docs/test.txt") is False
|
|
59
|
+
|
|
60
|
+
# Upload file
|
|
61
|
+
await storage.aupload(str(test_file), "docs/test.txt")
|
|
62
|
+
|
|
63
|
+
# Now it exists
|
|
64
|
+
assert await storage.aexists("docs/test.txt") is True
|
|
65
|
+
|
|
66
|
+
@pytest.mark.asyncio
|
|
67
|
+
async def test_delete(self, storage, test_file):
|
|
68
|
+
"""Test file deletion."""
|
|
69
|
+
# Upload file
|
|
70
|
+
await storage.aupload(str(test_file), "docs/test.txt")
|
|
71
|
+
assert await storage.aexists("docs/test.txt") is True
|
|
72
|
+
|
|
73
|
+
# Delete file
|
|
74
|
+
result = await storage.adelete("docs/test.txt")
|
|
75
|
+
|
|
76
|
+
assert result["deleted"] is True
|
|
77
|
+
assert await storage.aexists("docs/test.txt") is False
|
|
78
|
+
|
|
79
|
+
@pytest.mark.asyncio
|
|
80
|
+
async def test_list(self, storage, test_file):
|
|
81
|
+
"""Test file listing."""
|
|
82
|
+
# Upload multiple files
|
|
83
|
+
await storage.aupload(str(test_file), "docs/file1.txt")
|
|
84
|
+
await storage.aupload(str(test_file), "docs/file2.txt")
|
|
85
|
+
await storage.aupload(str(test_file), "images/pic.jpg")
|
|
86
|
+
|
|
87
|
+
# List all files
|
|
88
|
+
all_files = await storage.alist()
|
|
89
|
+
assert len(all_files) == 3
|
|
90
|
+
assert "docs/file1.txt" in all_files
|
|
91
|
+
assert "docs/file2.txt" in all_files
|
|
92
|
+
assert "images/pic.jpg" in all_files
|
|
93
|
+
|
|
94
|
+
# List with prefix
|
|
95
|
+
docs_files = await storage.alist(prefix="docs/")
|
|
96
|
+
assert len(docs_files) == 2
|
|
97
|
+
assert all(f.startswith("docs/") for f in docs_files)
|
|
98
|
+
|
|
99
|
+
@pytest.mark.asyncio
|
|
100
|
+
async def test_get_metadata(self, storage, test_file):
|
|
101
|
+
"""Test getting file metadata."""
|
|
102
|
+
await storage.aupload(str(test_file), "test.txt")
|
|
103
|
+
|
|
104
|
+
metadata = await storage.aget_metadata("test.txt")
|
|
105
|
+
|
|
106
|
+
assert metadata["path"] == "test.txt"
|
|
107
|
+
assert metadata["size_bytes"] > 0
|
|
108
|
+
assert metadata["is_file"] is True
|
|
109
|
+
assert "modified_time" in metadata
|
|
110
|
+
assert "created_time" in metadata
|
|
111
|
+
|
|
112
|
+
@pytest.mark.asyncio
|
|
113
|
+
async def test_health_check(self, storage):
|
|
114
|
+
"""Test health check."""
|
|
115
|
+
health = await storage.ahealth_check()
|
|
116
|
+
|
|
117
|
+
assert health.status == "healthy"
|
|
118
|
+
assert health.adapter_name == "local_file_storage"
|
|
119
|
+
assert health.port_name == "file_storage"
|
|
120
|
+
assert health.details["writable"] is True
|
|
121
|
+
|
|
122
|
+
@pytest.mark.asyncio
|
|
123
|
+
async def test_upload_creates_directories(self, storage, test_file):
|
|
124
|
+
"""Test that upload creates nested directories."""
|
|
125
|
+
result = await storage.aupload(str(test_file), "a/b/c/test.txt")
|
|
126
|
+
|
|
127
|
+
assert result["uploaded"] is True
|
|
128
|
+
assert await storage.aexists("a/b/c/test.txt") is True
|
|
129
|
+
|
|
130
|
+
@pytest.mark.asyncio
|
|
131
|
+
async def test_upload_nonexistent_file(self, storage):
|
|
132
|
+
"""Test uploading a nonexistent file."""
|
|
133
|
+
with pytest.raises(FileNotFoundError):
|
|
134
|
+
await storage.aupload("/nonexistent/file.txt", "test.txt")
|
|
135
|
+
|
|
136
|
+
@pytest.mark.asyncio
|
|
137
|
+
async def test_download_nonexistent_file(self, storage, tmp_path):
|
|
138
|
+
"""Test downloading a nonexistent file."""
|
|
139
|
+
with pytest.raises(FileNotFoundError):
|
|
140
|
+
await storage.adownload("nonexistent.txt", str(tmp_path / "out.txt"))
|
|
141
|
+
|
|
142
|
+
@pytest.mark.asyncio
|
|
143
|
+
async def test_delete_nonexistent_file(self, storage):
|
|
144
|
+
"""Test deleting a nonexistent file."""
|
|
145
|
+
with pytest.raises(FileNotFoundError):
|
|
146
|
+
await storage.adelete("nonexistent.txt")
|
|
147
|
+
|
|
148
|
+
@pytest.mark.asyncio
|
|
149
|
+
async def test_get_metadata_nonexistent_file(self, storage):
|
|
150
|
+
"""Test getting metadata for nonexistent file."""
|
|
151
|
+
with pytest.raises(FileNotFoundError):
|
|
152
|
+
await storage.aget_metadata("nonexistent.txt")
|
|
153
|
+
|
|
154
|
+
@pytest.mark.asyncio
|
|
155
|
+
async def test_repr(self, tmp_path):
|
|
156
|
+
"""Test string representation."""
|
|
157
|
+
storage = LocalFileStorage(base_path=str(tmp_path))
|
|
158
|
+
repr_str = repr(storage)
|
|
159
|
+
|
|
160
|
+
assert "LocalFileStorage" in repr_str
|
|
161
|
+
assert str(tmp_path) in repr_str
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Tests for SQL adapters (MySQL, PostgreSQL)."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from hexdag_plugins.storage.sql import MySQLAdapter, PostgreSQLAdapter
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestSQLAdapterInterface:
|
|
9
|
+
"""Test SQL adapter interface and basic functionality."""
|
|
10
|
+
|
|
11
|
+
@pytest.mark.asyncio
|
|
12
|
+
async def test_mysql_adapter_creation(self):
|
|
13
|
+
"""Test MySQL adapter can be created with config."""
|
|
14
|
+
adapter = MySQLAdapter(connection_string="mysql+aiomysql://test:test@localhost/test")
|
|
15
|
+
assert adapter is not None
|
|
16
|
+
assert (
|
|
17
|
+
adapter.config.connection_string.get_secret_value()
|
|
18
|
+
== "mysql+aiomysql://test:test@localhost/test"
|
|
19
|
+
)
|
|
20
|
+
assert adapter.config.pool_size == 5
|
|
21
|
+
assert adapter.config.max_overflow == 10
|
|
22
|
+
|
|
23
|
+
@pytest.mark.asyncio
|
|
24
|
+
async def test_postgresql_adapter_creation(self):
|
|
25
|
+
"""Test PostgreSQL adapter can be created with config."""
|
|
26
|
+
adapter = PostgreSQLAdapter(
|
|
27
|
+
connection_string="postgresql+asyncpg://test:test@localhost/test"
|
|
28
|
+
)
|
|
29
|
+
assert adapter is not None
|
|
30
|
+
assert (
|
|
31
|
+
adapter.config.connection_string.get_secret_value()
|
|
32
|
+
== "postgresql+asyncpg://test:test@localhost/test"
|
|
33
|
+
)
|
|
34
|
+
assert adapter.config.pool_size == 5
|
|
35
|
+
assert adapter.config.max_overflow == 10
|
|
36
|
+
|
|
37
|
+
@pytest.mark.asyncio
|
|
38
|
+
async def test_mysql_adapter_custom_pool_config(self):
|
|
39
|
+
"""Test MySQL adapter with custom pool configuration."""
|
|
40
|
+
adapter = MySQLAdapter(
|
|
41
|
+
connection_string="mysql+aiomysql://test:test@localhost/test",
|
|
42
|
+
pool_size=10,
|
|
43
|
+
max_overflow=20,
|
|
44
|
+
pool_timeout=60.0,
|
|
45
|
+
pool_recycle=7200,
|
|
46
|
+
pool_pre_ping=False,
|
|
47
|
+
)
|
|
48
|
+
assert adapter.config.pool_size == 10
|
|
49
|
+
assert adapter.config.max_overflow == 20
|
|
50
|
+
assert adapter.config.pool_timeout == 60.0
|
|
51
|
+
assert adapter.config.pool_recycle == 7200
|
|
52
|
+
assert adapter.config.pool_pre_ping is False
|
|
53
|
+
|
|
54
|
+
@pytest.mark.asyncio
|
|
55
|
+
async def test_postgresql_adapter_custom_pool_config(self):
|
|
56
|
+
"""Test PostgreSQL adapter with custom pool configuration."""
|
|
57
|
+
adapter = PostgreSQLAdapter(
|
|
58
|
+
connection_string="postgresql+asyncpg://test:test@localhost/test",
|
|
59
|
+
pool_size=15,
|
|
60
|
+
max_overflow=25,
|
|
61
|
+
pool_timeout=45.0,
|
|
62
|
+
pool_recycle=1800,
|
|
63
|
+
pool_pre_ping=False,
|
|
64
|
+
)
|
|
65
|
+
assert adapter.config.pool_size == 15
|
|
66
|
+
assert adapter.config.max_overflow == 25
|
|
67
|
+
assert adapter.config.pool_timeout == 45.0
|
|
68
|
+
assert adapter.config.pool_recycle == 1800
|
|
69
|
+
assert adapter.config.pool_pre_ping is False
|
|
70
|
+
|
|
71
|
+
@pytest.mark.asyncio
|
|
72
|
+
async def test_adapter_not_setup_error(self):
|
|
73
|
+
"""Test that operations fail before setup."""
|
|
74
|
+
adapter = MySQLAdapter(connection_string="mysql+aiomysql://test:test@localhost/test")
|
|
75
|
+
|
|
76
|
+
with pytest.raises(RuntimeError, match="Adapter not set up"):
|
|
77
|
+
await adapter.aexecute("SELECT 1")
|
|
78
|
+
|
|
79
|
+
with pytest.raises(RuntimeError, match="Adapter not set up"):
|
|
80
|
+
await adapter.afetch_one("SELECT 1")
|
|
81
|
+
|
|
82
|
+
with pytest.raises(RuntimeError, match="Adapter not set up"):
|
|
83
|
+
await adapter.afetch_all("SELECT 1")
|
|
84
|
+
|
|
85
|
+
@pytest.mark.asyncio
|
|
86
|
+
async def test_health_check_before_setup(self):
|
|
87
|
+
"""Test health check before setup returns unhealthy status."""
|
|
88
|
+
adapter = MySQLAdapter(connection_string="mysql+aiomysql://test:test@localhost/test")
|
|
89
|
+
|
|
90
|
+
health = await adapter.ahealth_check()
|
|
91
|
+
assert health.status == "unhealthy"
|
|
92
|
+
assert "not initialized" in health.details.get("message", "").lower()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# Integration tests require real database connections
|
|
96
|
+
# These are marked with @pytest.mark.integration and skipped by default
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@pytest.mark.integration
|
|
100
|
+
@pytest.mark.asyncio
|
|
101
|
+
async def test_mysql_adapter_integration():
|
|
102
|
+
"""Integration test for MySQL adapter with real database.
|
|
103
|
+
|
|
104
|
+
Requires MySQL running at localhost with test database.
|
|
105
|
+
Set MYSQL_TEST_URL environment variable or skip this test.
|
|
106
|
+
"""
|
|
107
|
+
import os
|
|
108
|
+
|
|
109
|
+
connection_string = os.getenv("MYSQL_TEST_URL", "mysql+aiomysql://test:test@localhost/test")
|
|
110
|
+
|
|
111
|
+
adapter = MySQLAdapter(connection_string=connection_string)
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
await adapter.asetup()
|
|
115
|
+
|
|
116
|
+
# Test health check
|
|
117
|
+
health = await adapter.ahealth_check()
|
|
118
|
+
assert health.is_healthy() is True
|
|
119
|
+
|
|
120
|
+
# Test table creation
|
|
121
|
+
await adapter.aexecute(
|
|
122
|
+
"""
|
|
123
|
+
CREATE TABLE IF NOT EXISTS test_table (
|
|
124
|
+
id INT PRIMARY KEY AUTO_INCREMENT,
|
|
125
|
+
name VARCHAR(255),
|
|
126
|
+
value INT
|
|
127
|
+
)
|
|
128
|
+
"""
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Test insert
|
|
132
|
+
await adapter.aexecute(
|
|
133
|
+
"INSERT INTO test_table (name, value) VALUES (:name, :value)",
|
|
134
|
+
{"name": "test", "value": 42},
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Test fetch_one
|
|
138
|
+
result = await adapter.afetch_one(
|
|
139
|
+
"SELECT * FROM test_table WHERE name = :name", {"name": "test"}
|
|
140
|
+
)
|
|
141
|
+
assert result is not None
|
|
142
|
+
assert result["name"] == "test"
|
|
143
|
+
assert result["value"] == 42
|
|
144
|
+
|
|
145
|
+
# Test fetch_all
|
|
146
|
+
results = await adapter.afetch_all("SELECT * FROM test_table")
|
|
147
|
+
assert len(results) >= 1
|
|
148
|
+
|
|
149
|
+
# Cleanup
|
|
150
|
+
await adapter.aexecute("DROP TABLE test_table")
|
|
151
|
+
|
|
152
|
+
finally:
|
|
153
|
+
await adapter.aclose()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@pytest.mark.integration
|
|
157
|
+
@pytest.mark.asyncio
|
|
158
|
+
async def test_postgresql_adapter_integration():
|
|
159
|
+
"""Integration test for PostgreSQL adapter with real database.
|
|
160
|
+
|
|
161
|
+
Requires PostgreSQL running at localhost with test database.
|
|
162
|
+
Set POSTGRES_TEST_URL environment variable or skip this test.
|
|
163
|
+
"""
|
|
164
|
+
import os
|
|
165
|
+
|
|
166
|
+
connection_string = os.getenv(
|
|
167
|
+
"POSTGRES_TEST_URL", "postgresql+asyncpg://test:test@localhost/test"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
adapter = PostgreSQLAdapter(connection_string=connection_string)
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
await adapter.asetup()
|
|
174
|
+
|
|
175
|
+
# Test health check
|
|
176
|
+
health = await adapter.ahealth_check()
|
|
177
|
+
assert health.is_healthy() is True
|
|
178
|
+
|
|
179
|
+
# Test table creation
|
|
180
|
+
await adapter.aexecute(
|
|
181
|
+
"""
|
|
182
|
+
CREATE TABLE IF NOT EXISTS test_table (
|
|
183
|
+
id SERIAL PRIMARY KEY,
|
|
184
|
+
name VARCHAR(255),
|
|
185
|
+
value INT
|
|
186
|
+
)
|
|
187
|
+
"""
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Test insert
|
|
191
|
+
await adapter.aexecute(
|
|
192
|
+
"INSERT INTO test_table (name, value) VALUES (:name, :value)",
|
|
193
|
+
{"name": "test", "value": 42},
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Test fetch_one
|
|
197
|
+
result = await adapter.afetch_one(
|
|
198
|
+
"SELECT * FROM test_table WHERE name = :name", {"name": "test"}
|
|
199
|
+
)
|
|
200
|
+
assert result is not None
|
|
201
|
+
assert result["name"] == "test"
|
|
202
|
+
assert result["value"] == 42
|
|
203
|
+
|
|
204
|
+
# Test fetch_all
|
|
205
|
+
results = await adapter.afetch_all("SELECT * FROM test_table")
|
|
206
|
+
assert len(results) >= 1
|
|
207
|
+
|
|
208
|
+
# Cleanup
|
|
209
|
+
await adapter.aexecute("DROP TABLE test_table")
|
|
210
|
+
|
|
211
|
+
finally:
|
|
212
|
+
await adapter.aclose()
|