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.
Files changed (261) hide show
  1. hexdag/__init__.py +116 -0
  2. hexdag/__main__.py +30 -0
  3. hexdag/adapters/executors/__init__.py +5 -0
  4. hexdag/adapters/executors/local_executor.py +316 -0
  5. hexdag/builtin/__init__.py +6 -0
  6. hexdag/builtin/adapters/__init__.py +51 -0
  7. hexdag/builtin/adapters/anthropic/__init__.py +5 -0
  8. hexdag/builtin/adapters/anthropic/anthropic_adapter.py +151 -0
  9. hexdag/builtin/adapters/database/__init__.py +6 -0
  10. hexdag/builtin/adapters/database/csv/csv_adapter.py +249 -0
  11. hexdag/builtin/adapters/database/pgvector/__init__.py +5 -0
  12. hexdag/builtin/adapters/database/pgvector/pgvector_adapter.py +478 -0
  13. hexdag/builtin/adapters/database/sqlalchemy/sqlalchemy_adapter.py +252 -0
  14. hexdag/builtin/adapters/database/sqlite/__init__.py +5 -0
  15. hexdag/builtin/adapters/database/sqlite/sqlite_adapter.py +410 -0
  16. hexdag/builtin/adapters/local/README.md +59 -0
  17. hexdag/builtin/adapters/local/__init__.py +7 -0
  18. hexdag/builtin/adapters/local/local_observer_manager.py +696 -0
  19. hexdag/builtin/adapters/memory/__init__.py +47 -0
  20. hexdag/builtin/adapters/memory/file_memory_adapter.py +297 -0
  21. hexdag/builtin/adapters/memory/in_memory_memory.py +216 -0
  22. hexdag/builtin/adapters/memory/schemas.py +57 -0
  23. hexdag/builtin/adapters/memory/session_memory.py +178 -0
  24. hexdag/builtin/adapters/memory/sqlite_memory_adapter.py +215 -0
  25. hexdag/builtin/adapters/memory/state_memory.py +280 -0
  26. hexdag/builtin/adapters/mock/README.md +89 -0
  27. hexdag/builtin/adapters/mock/__init__.py +15 -0
  28. hexdag/builtin/adapters/mock/hexdag.toml +50 -0
  29. hexdag/builtin/adapters/mock/mock_database.py +225 -0
  30. hexdag/builtin/adapters/mock/mock_embedding.py +223 -0
  31. hexdag/builtin/adapters/mock/mock_llm.py +177 -0
  32. hexdag/builtin/adapters/mock/mock_tool_adapter.py +192 -0
  33. hexdag/builtin/adapters/mock/mock_tool_router.py +232 -0
  34. hexdag/builtin/adapters/openai/__init__.py +5 -0
  35. hexdag/builtin/adapters/openai/openai_adapter.py +634 -0
  36. hexdag/builtin/adapters/secret/__init__.py +7 -0
  37. hexdag/builtin/adapters/secret/local_secret_adapter.py +248 -0
  38. hexdag/builtin/adapters/unified_tool_router.py +280 -0
  39. hexdag/builtin/macros/__init__.py +17 -0
  40. hexdag/builtin/macros/conversation_agent.py +390 -0
  41. hexdag/builtin/macros/llm_macro.py +151 -0
  42. hexdag/builtin/macros/reasoning_agent.py +423 -0
  43. hexdag/builtin/macros/tool_macro.py +380 -0
  44. hexdag/builtin/nodes/__init__.py +38 -0
  45. hexdag/builtin/nodes/_discovery.py +123 -0
  46. hexdag/builtin/nodes/agent_node.py +696 -0
  47. hexdag/builtin/nodes/base_node_factory.py +242 -0
  48. hexdag/builtin/nodes/composite_node.py +926 -0
  49. hexdag/builtin/nodes/data_node.py +201 -0
  50. hexdag/builtin/nodes/expression_node.py +487 -0
  51. hexdag/builtin/nodes/function_node.py +454 -0
  52. hexdag/builtin/nodes/llm_node.py +491 -0
  53. hexdag/builtin/nodes/loop_node.py +920 -0
  54. hexdag/builtin/nodes/mapped_input.py +518 -0
  55. hexdag/builtin/nodes/port_call_node.py +269 -0
  56. hexdag/builtin/nodes/tool_call_node.py +195 -0
  57. hexdag/builtin/nodes/tool_utils.py +390 -0
  58. hexdag/builtin/prompts/__init__.py +68 -0
  59. hexdag/builtin/prompts/base.py +422 -0
  60. hexdag/builtin/prompts/chat_prompts.py +303 -0
  61. hexdag/builtin/prompts/error_correction_prompts.py +320 -0
  62. hexdag/builtin/prompts/tool_prompts.py +160 -0
  63. hexdag/builtin/tools/builtin_tools.py +84 -0
  64. hexdag/builtin/tools/database_tools.py +164 -0
  65. hexdag/cli/__init__.py +17 -0
  66. hexdag/cli/__main__.py +7 -0
  67. hexdag/cli/commands/__init__.py +27 -0
  68. hexdag/cli/commands/build_cmd.py +812 -0
  69. hexdag/cli/commands/create_cmd.py +208 -0
  70. hexdag/cli/commands/docs_cmd.py +293 -0
  71. hexdag/cli/commands/generate_types_cmd.py +252 -0
  72. hexdag/cli/commands/init_cmd.py +188 -0
  73. hexdag/cli/commands/pipeline_cmd.py +494 -0
  74. hexdag/cli/commands/plugin_dev_cmd.py +529 -0
  75. hexdag/cli/commands/plugins_cmd.py +441 -0
  76. hexdag/cli/commands/studio_cmd.py +101 -0
  77. hexdag/cli/commands/validate_cmd.py +221 -0
  78. hexdag/cli/main.py +84 -0
  79. hexdag/core/__init__.py +83 -0
  80. hexdag/core/config/__init__.py +20 -0
  81. hexdag/core/config/loader.py +479 -0
  82. hexdag/core/config/models.py +150 -0
  83. hexdag/core/configurable.py +294 -0
  84. hexdag/core/context/__init__.py +37 -0
  85. hexdag/core/context/execution_context.py +378 -0
  86. hexdag/core/docs/__init__.py +26 -0
  87. hexdag/core/docs/extractors.py +678 -0
  88. hexdag/core/docs/generators.py +890 -0
  89. hexdag/core/docs/models.py +120 -0
  90. hexdag/core/domain/__init__.py +10 -0
  91. hexdag/core/domain/dag.py +1225 -0
  92. hexdag/core/exceptions.py +234 -0
  93. hexdag/core/expression_parser.py +569 -0
  94. hexdag/core/logging.py +449 -0
  95. hexdag/core/models/__init__.py +17 -0
  96. hexdag/core/models/base.py +138 -0
  97. hexdag/core/orchestration/__init__.py +46 -0
  98. hexdag/core/orchestration/body_executor.py +481 -0
  99. hexdag/core/orchestration/components/__init__.py +97 -0
  100. hexdag/core/orchestration/components/adapter_lifecycle_manager.py +113 -0
  101. hexdag/core/orchestration/components/checkpoint_manager.py +134 -0
  102. hexdag/core/orchestration/components/execution_coordinator.py +360 -0
  103. hexdag/core/orchestration/components/health_check_manager.py +176 -0
  104. hexdag/core/orchestration/components/input_mapper.py +143 -0
  105. hexdag/core/orchestration/components/lifecycle_manager.py +583 -0
  106. hexdag/core/orchestration/components/node_executor.py +377 -0
  107. hexdag/core/orchestration/components/secret_manager.py +202 -0
  108. hexdag/core/orchestration/components/wave_executor.py +158 -0
  109. hexdag/core/orchestration/constants.py +17 -0
  110. hexdag/core/orchestration/events/README.md +312 -0
  111. hexdag/core/orchestration/events/__init__.py +104 -0
  112. hexdag/core/orchestration/events/batching.py +330 -0
  113. hexdag/core/orchestration/events/decorators.py +139 -0
  114. hexdag/core/orchestration/events/events.py +573 -0
  115. hexdag/core/orchestration/events/observers/__init__.py +30 -0
  116. hexdag/core/orchestration/events/observers/core_observers.py +690 -0
  117. hexdag/core/orchestration/events/observers/models.py +111 -0
  118. hexdag/core/orchestration/events/taxonomy.py +269 -0
  119. hexdag/core/orchestration/hook_context.py +237 -0
  120. hexdag/core/orchestration/hooks.py +437 -0
  121. hexdag/core/orchestration/models.py +418 -0
  122. hexdag/core/orchestration/orchestrator.py +910 -0
  123. hexdag/core/orchestration/orchestrator_factory.py +275 -0
  124. hexdag/core/orchestration/port_wrappers.py +327 -0
  125. hexdag/core/orchestration/prompt/__init__.py +32 -0
  126. hexdag/core/orchestration/prompt/template.py +332 -0
  127. hexdag/core/pipeline_builder/__init__.py +21 -0
  128. hexdag/core/pipeline_builder/component_instantiator.py +386 -0
  129. hexdag/core/pipeline_builder/include_tag.py +265 -0
  130. hexdag/core/pipeline_builder/pipeline_config.py +133 -0
  131. hexdag/core/pipeline_builder/py_tag.py +223 -0
  132. hexdag/core/pipeline_builder/tag_discovery.py +268 -0
  133. hexdag/core/pipeline_builder/yaml_builder.py +1196 -0
  134. hexdag/core/pipeline_builder/yaml_validator.py +569 -0
  135. hexdag/core/ports/__init__.py +65 -0
  136. hexdag/core/ports/api_call.py +133 -0
  137. hexdag/core/ports/database.py +489 -0
  138. hexdag/core/ports/embedding.py +215 -0
  139. hexdag/core/ports/executor.py +237 -0
  140. hexdag/core/ports/file_storage.py +117 -0
  141. hexdag/core/ports/healthcheck.py +87 -0
  142. hexdag/core/ports/llm.py +551 -0
  143. hexdag/core/ports/memory.py +70 -0
  144. hexdag/core/ports/observer_manager.py +130 -0
  145. hexdag/core/ports/secret.py +145 -0
  146. hexdag/core/ports/tool_router.py +94 -0
  147. hexdag/core/ports_builder.py +623 -0
  148. hexdag/core/protocols.py +273 -0
  149. hexdag/core/resolver.py +304 -0
  150. hexdag/core/schema/__init__.py +9 -0
  151. hexdag/core/schema/generator.py +742 -0
  152. hexdag/core/secrets.py +242 -0
  153. hexdag/core/types.py +413 -0
  154. hexdag/core/utils/async_warnings.py +206 -0
  155. hexdag/core/utils/schema_conversion.py +78 -0
  156. hexdag/core/utils/sql_validation.py +86 -0
  157. hexdag/core/validation/secure_json.py +148 -0
  158. hexdag/core/yaml_macro.py +517 -0
  159. hexdag/mcp_server.py +3120 -0
  160. hexdag/studio/__init__.py +10 -0
  161. hexdag/studio/build_ui.py +92 -0
  162. hexdag/studio/server/__init__.py +1 -0
  163. hexdag/studio/server/main.py +100 -0
  164. hexdag/studio/server/routes/__init__.py +9 -0
  165. hexdag/studio/server/routes/execute.py +208 -0
  166. hexdag/studio/server/routes/export.py +558 -0
  167. hexdag/studio/server/routes/files.py +207 -0
  168. hexdag/studio/server/routes/plugins.py +419 -0
  169. hexdag/studio/server/routes/validate.py +220 -0
  170. hexdag/studio/ui/index.html +13 -0
  171. hexdag/studio/ui/package-lock.json +2992 -0
  172. hexdag/studio/ui/package.json +31 -0
  173. hexdag/studio/ui/postcss.config.js +6 -0
  174. hexdag/studio/ui/public/hexdag.svg +5 -0
  175. hexdag/studio/ui/src/App.tsx +251 -0
  176. hexdag/studio/ui/src/components/Canvas.tsx +408 -0
  177. hexdag/studio/ui/src/components/ContextMenu.tsx +187 -0
  178. hexdag/studio/ui/src/components/FileBrowser.tsx +123 -0
  179. hexdag/studio/ui/src/components/Header.tsx +181 -0
  180. hexdag/studio/ui/src/components/HexdagNode.tsx +193 -0
  181. hexdag/studio/ui/src/components/NodeInspector.tsx +512 -0
  182. hexdag/studio/ui/src/components/NodePalette.tsx +262 -0
  183. hexdag/studio/ui/src/components/NodePortsSection.tsx +403 -0
  184. hexdag/studio/ui/src/components/PluginManager.tsx +347 -0
  185. hexdag/studio/ui/src/components/PortsEditor.tsx +481 -0
  186. hexdag/studio/ui/src/components/PythonEditor.tsx +195 -0
  187. hexdag/studio/ui/src/components/ValidationPanel.tsx +105 -0
  188. hexdag/studio/ui/src/components/YamlEditor.tsx +196 -0
  189. hexdag/studio/ui/src/components/index.ts +8 -0
  190. hexdag/studio/ui/src/index.css +92 -0
  191. hexdag/studio/ui/src/main.tsx +10 -0
  192. hexdag/studio/ui/src/types/index.ts +123 -0
  193. hexdag/studio/ui/src/vite-env.d.ts +1 -0
  194. hexdag/studio/ui/tailwind.config.js +29 -0
  195. hexdag/studio/ui/tsconfig.json +37 -0
  196. hexdag/studio/ui/tsconfig.node.json +13 -0
  197. hexdag/studio/ui/vite.config.ts +35 -0
  198. hexdag/visualization/__init__.py +69 -0
  199. hexdag/visualization/dag_visualizer.py +1020 -0
  200. hexdag-0.5.0.dev1.dist-info/METADATA +369 -0
  201. hexdag-0.5.0.dev1.dist-info/RECORD +261 -0
  202. hexdag-0.5.0.dev1.dist-info/WHEEL +4 -0
  203. hexdag-0.5.0.dev1.dist-info/entry_points.txt +4 -0
  204. hexdag-0.5.0.dev1.dist-info/licenses/LICENSE +190 -0
  205. hexdag_plugins/.gitignore +43 -0
  206. hexdag_plugins/README.md +73 -0
  207. hexdag_plugins/__init__.py +1 -0
  208. hexdag_plugins/azure/LICENSE +21 -0
  209. hexdag_plugins/azure/README.md +414 -0
  210. hexdag_plugins/azure/__init__.py +21 -0
  211. hexdag_plugins/azure/azure_blob_adapter.py +450 -0
  212. hexdag_plugins/azure/azure_cosmos_adapter.py +383 -0
  213. hexdag_plugins/azure/azure_keyvault_adapter.py +314 -0
  214. hexdag_plugins/azure/azure_openai_adapter.py +415 -0
  215. hexdag_plugins/azure/pyproject.toml +107 -0
  216. hexdag_plugins/azure/tests/__init__.py +1 -0
  217. hexdag_plugins/azure/tests/test_azure_blob_adapter.py +350 -0
  218. hexdag_plugins/azure/tests/test_azure_cosmos_adapter.py +323 -0
  219. hexdag_plugins/azure/tests/test_azure_keyvault_adapter.py +330 -0
  220. hexdag_plugins/azure/tests/test_azure_openai_adapter.py +329 -0
  221. hexdag_plugins/hexdag_etl/README.md +168 -0
  222. hexdag_plugins/hexdag_etl/__init__.py +53 -0
  223. hexdag_plugins/hexdag_etl/examples/01_simple_pandas_transform.py +270 -0
  224. hexdag_plugins/hexdag_etl/examples/02_simple_pandas_only.py +149 -0
  225. hexdag_plugins/hexdag_etl/examples/03_file_io_pipeline.py +109 -0
  226. hexdag_plugins/hexdag_etl/examples/test_pandas_transform.py +84 -0
  227. hexdag_plugins/hexdag_etl/hexdag.toml +25 -0
  228. hexdag_plugins/hexdag_etl/hexdag_etl/__init__.py +48 -0
  229. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/__init__.py +13 -0
  230. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/api_extract.py +230 -0
  231. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/base_node_factory.py +181 -0
  232. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/file_io.py +415 -0
  233. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/outlook.py +492 -0
  234. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/pandas_transform.py +563 -0
  235. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/sql_extract_load.py +112 -0
  236. hexdag_plugins/hexdag_etl/pyproject.toml +82 -0
  237. hexdag_plugins/hexdag_etl/test_transform.py +54 -0
  238. hexdag_plugins/hexdag_etl/tests/test_plugin_integration.py +62 -0
  239. hexdag_plugins/mysql_adapter/LICENSE +21 -0
  240. hexdag_plugins/mysql_adapter/README.md +224 -0
  241. hexdag_plugins/mysql_adapter/__init__.py +6 -0
  242. hexdag_plugins/mysql_adapter/mysql_adapter.py +408 -0
  243. hexdag_plugins/mysql_adapter/pyproject.toml +93 -0
  244. hexdag_plugins/mysql_adapter/tests/test_mysql_adapter.py +259 -0
  245. hexdag_plugins/storage/README.md +184 -0
  246. hexdag_plugins/storage/__init__.py +19 -0
  247. hexdag_plugins/storage/file/__init__.py +5 -0
  248. hexdag_plugins/storage/file/local.py +325 -0
  249. hexdag_plugins/storage/ports/__init__.py +5 -0
  250. hexdag_plugins/storage/ports/vector_store.py +236 -0
  251. hexdag_plugins/storage/sql/__init__.py +7 -0
  252. hexdag_plugins/storage/sql/base.py +187 -0
  253. hexdag_plugins/storage/sql/mysql.py +27 -0
  254. hexdag_plugins/storage/sql/postgresql.py +27 -0
  255. hexdag_plugins/storage/tests/__init__.py +1 -0
  256. hexdag_plugins/storage/tests/test_local_file_storage.py +161 -0
  257. hexdag_plugins/storage/tests/test_sql_adapters.py +212 -0
  258. hexdag_plugins/storage/vector/__init__.py +7 -0
  259. hexdag_plugins/storage/vector/chromadb.py +223 -0
  260. hexdag_plugins/storage/vector/in_memory.py +285 -0
  261. hexdag_plugins/storage/vector/pgvector.py +502 -0
@@ -0,0 +1,252 @@
1
+ # type: ignore
2
+ """SQLAlchemy adapter implementation for hexDAG."""
3
+
4
+ from collections.abc import AsyncIterator, Sequence
5
+ from typing import Any
6
+
7
+ from sqlalchemy import MetaData, Table, inspect, select, text
8
+ from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
9
+
10
+ from hexdag.core.ports.database import (
11
+ ColumnSchema,
12
+ DatabasePort,
13
+ SupportsIndexes,
14
+ SupportsRawSQL,
15
+ SupportsStatistics,
16
+ TableSchema,
17
+ )
18
+
19
+
20
+ class SQLAlchemyAdapter(DatabasePort, SupportsRawSQL, SupportsIndexes, SupportsStatistics):
21
+ """Adapter for SQLAlchemy-supported databases."""
22
+
23
+ def __init__(self, dsn: str) -> None:
24
+ """
25
+ Initialize SQLAlchemy adapter.
26
+
27
+ Args:
28
+ dsn: Database connection string (e.g., postgresql+asyncpg://user:pass@localhost/db)
29
+ """
30
+ self.engine: AsyncEngine | None = None
31
+ self.dsn = dsn
32
+ self._metadata = MetaData()
33
+
34
+ async def connect(self) -> None:
35
+ """Establish database connection."""
36
+ self.engine = create_async_engine(self.dsn)
37
+ async with self.engine.connect() as conn:
38
+ # Force reflection after any schema changes
39
+ await conn.run_sync(self._metadata.reflect)
40
+
41
+ async def disconnect(self) -> None:
42
+ """Close database connection."""
43
+ if self.engine:
44
+ await self.engine.dispose()
45
+ self.engine = None
46
+
47
+ async def aget_table_schemas(self) -> dict[str, dict[str, Any]]:
48
+ """
49
+ Get schema information for all tables in DatabasePort format.
50
+
51
+ Returns:
52
+ Dictionary mapping table names to schema information.
53
+ """
54
+ if not self.engine:
55
+ raise RuntimeError("Not connected to database")
56
+
57
+ schemas = {}
58
+ for table_name, table in self._metadata.tables.items():
59
+ columns = {}
60
+ primary_keys = []
61
+ foreign_keys = []
62
+
63
+ for col in table.columns:
64
+ columns[col.name] = str(col.type)
65
+
66
+ if col.primary_key:
67
+ primary_keys.append(col.name)
68
+
69
+ if col.foreign_keys:
70
+ fk = next(iter(col.foreign_keys))
71
+ foreign_keys.append({
72
+ "from_column": col.name,
73
+ "to_table": fk.column.table.name,
74
+ "to_column": fk.column.name,
75
+ })
76
+
77
+ schemas[table_name] = {
78
+ "table_name": table_name,
79
+ "columns": columns,
80
+ "primary_keys": primary_keys,
81
+ "foreign_keys": foreign_keys,
82
+ }
83
+
84
+ return schemas
85
+
86
+ async def aexecute_query(
87
+ self, query: str, params: dict[str, Any] | None = None
88
+ ) -> list[dict[str, Any]]:
89
+ """Execute a SQL query and return results.
90
+
91
+ Args:
92
+ query: SQL query string
93
+ params: Optional query parameters for safe parameterized queries
94
+
95
+ Returns:
96
+ List of dictionaries representing query result rows
97
+ """
98
+ if not self.engine:
99
+ raise RuntimeError("Not connected to database")
100
+
101
+ results = []
102
+ async with self.engine.connect() as conn:
103
+ result = await conn.execute(text(query), params or {})
104
+ results.extend(dict(row._mapping) for row in result)
105
+ return results
106
+
107
+ async def get_table_schemas(self) -> Sequence[TableSchema]:
108
+ """
109
+ Get schema information for all tables.
110
+
111
+ Returns:
112
+ Sequence[TableSchema]: List of table schemas
113
+ """
114
+ if not self.engine:
115
+ raise RuntimeError("Not connected to database")
116
+
117
+ schemas = []
118
+ for table_name, table in self._metadata.tables.items():
119
+ columns = []
120
+ for col in table.columns:
121
+ foreign_key = None
122
+ if col.foreign_keys:
123
+ fk = next(iter(col.foreign_keys))
124
+ foreign_key = f"{fk.column.table.name}.{fk.column.name}"
125
+
126
+ nullable = bool(col.nullable) if col.nullable is not None else False
127
+
128
+ columns.append(
129
+ ColumnSchema(
130
+ name=col.name,
131
+ type=col.type.value,
132
+ nullable=nullable,
133
+ primary_key=col.primary_key,
134
+ foreign_key=foreign_key,
135
+ )
136
+ )
137
+ schemas.append(TableSchema(name=table_name, columns=columns))
138
+
139
+ return schemas
140
+
141
+ def query(
142
+ self,
143
+ table: str,
144
+ filters: dict[str, Any] | None = None,
145
+ columns: list[str] | None = None,
146
+ limit: int | None = None,
147
+ ) -> AsyncIterator[dict[str, Any]]:
148
+ """
149
+ Query rows from a table with filtering and column selection.
150
+
151
+ Args:
152
+ table: Table name
153
+ filters: Optional column-value pairs to filter by
154
+ columns: Optional list of columns to return (None = all)
155
+ limit: Optional maximum number of rows to return
156
+ Returns:
157
+ AsyncIterator[dict[str, Any]]: An async iterator over rows as dictionaries
158
+ """
159
+ if not self.engine:
160
+ raise RuntimeError("Not connected to database")
161
+
162
+ table_obj = Table(table, self._metadata, extend_existing=True)
163
+ query = select(table_obj)
164
+
165
+ if columns:
166
+ query = select(*[table_obj.c[col] for col in columns])
167
+
168
+ if filters:
169
+ conditions = [table_obj.c[k] == v for k, v in filters.items()]
170
+ query = query.where(*conditions)
171
+
172
+ if limit:
173
+ query = query.limit(limit)
174
+
175
+ async def generate_rows() -> AsyncIterator[dict[str, Any]]:
176
+ if not self.engine: # Recheck engine in case it was closed
177
+ raise RuntimeError("Database connection lost")
178
+ async with self.engine.connect() as conn:
179
+ result = await conn.stream(query)
180
+ async for row in result:
181
+ yield dict(row._mapping)
182
+
183
+ return generate_rows()
184
+
185
+ def query_raw(
186
+ self, sql: str, params: dict[str, Any] | None = None
187
+ ) -> AsyncIterator[dict[str, Any]]:
188
+ """Execute a raw SQL query."""
189
+ if not self.engine:
190
+ raise RuntimeError("Not connected to database")
191
+
192
+ async def generate_rows() -> AsyncIterator[dict[str, Any]]:
193
+ if not self.engine:
194
+ raise RuntimeError("Database connection lost")
195
+ async with self.engine.connect() as conn:
196
+ result = await conn.stream(text(sql), params or {})
197
+ async for row in result:
198
+ yield dict(row._mapping)
199
+
200
+ return generate_rows()
201
+
202
+ async def execute_raw(self, sql: str, params: dict[str, Any] | None = None) -> None:
203
+ """
204
+ Execute a raw SQL statement without returning results.
205
+
206
+ Args:
207
+ sql: SQL statement to execute
208
+ params: Optional parameters for the SQL statement
209
+ """
210
+ if not self.engine:
211
+ raise RuntimeError("Not connected to database")
212
+
213
+ async with self.engine.connect() as conn:
214
+ await conn.execute(text(sql), params or {})
215
+ await conn.commit()
216
+
217
+ async def get_indexes(self, table: str) -> list[str]:
218
+ """
219
+ Get index information for a table.
220
+
221
+ Args:
222
+ table: Table name
223
+
224
+ Returns:
225
+ list[str]: List of index names
226
+ """
227
+ if not self.engine:
228
+ raise RuntimeError("Not connected to database")
229
+
230
+ async with self.engine.connect() as conn:
231
+ # Remove engine argument from inspect call
232
+ inspector = await conn.run_sync(inspect)
233
+ # Filter out None values to ensure list[str]
234
+ return [idx["name"] for idx in inspector.get_indexes(table) if idx["name"] is not None]
235
+
236
+ async def get_table_statistics(self, table: str) -> dict[str, int]:
237
+ """
238
+ Get statistical information about a table.
239
+
240
+ Args:
241
+ table: Table name
242
+
243
+ Returns:
244
+ dict[str, int]: Statistics including row count
245
+ """
246
+ if not self.engine:
247
+ raise RuntimeError("Not connected to database")
248
+
249
+ async with self.engine.connect() as conn:
250
+ result = await conn.execute(text(f"SELECT COUNT(*) as count FROM {table}")) # nosec
251
+ row = await result.fetchone() # type: ignore # type: ignore
252
+ return {"row_count": int(row[0]) if row else 0}
@@ -0,0 +1,5 @@
1
+ """SQLite adapter for hexDAG."""
2
+
3
+ from hexdag.builtin.adapters.database.sqlite.sqlite_adapter import SQLiteAdapter
4
+
5
+ __all__ = ["SQLiteAdapter"]
@@ -0,0 +1,410 @@
1
+ """SQLite database adapter implementation with async support."""
2
+
3
+ import re
4
+ from collections.abc import AsyncIterator
5
+ from contextlib import asynccontextmanager
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import aiosqlite
10
+
11
+ from hexdag.core.logging import get_logger
12
+ from hexdag.core.utils.sql_validation import validate_sql_identifier
13
+
14
+ logger = get_logger(__name__)
15
+
16
+
17
+ class SQLiteAdapter:
18
+ """Async SQLite adapter for database port.
19
+
20
+ Provides a lightweight, file-based database solution that implements
21
+ the DatabasePort interface for SQL execution and schema introspection.
22
+ All operations are fully async using aiosqlite.
23
+ """
24
+
25
+ def __init__(self, **kwargs: Any) -> None:
26
+ """Initialize SQLite adapter.
27
+
28
+ Args
29
+ ----
30
+ **kwargs: Configuration options (db_path, timeout, etc.)
31
+ """
32
+ db_path = kwargs.get("db_path", ":memory:")
33
+ self.db_path = Path(db_path) if db_path != ":memory:" else db_path
34
+ self.check_same_thread = kwargs.get("check_same_thread", False)
35
+ self.timeout = kwargs.get("timeout", 5.0)
36
+ self.journal_mode = kwargs.get("journal_mode", "WAL")
37
+ self.foreign_keys = kwargs.get("foreign_keys", True)
38
+ self.read_only = kwargs.get("read_only", False)
39
+
40
+ self.connection: aiosqlite.Connection | None = None
41
+
42
+ async def _ensure_database(self) -> None:
43
+ """Ensure database connection exists and is configured."""
44
+ if self.connection is not None:
45
+ return
46
+
47
+ db_path = str(self.db_path) if isinstance(self.db_path, Path) else self.db_path
48
+
49
+ if self.read_only and db_path != ":memory:":
50
+ # Use URI mode for read-only access
51
+ db_uri = f"file:{db_path}?mode=ro"
52
+ self.connection = await aiosqlite.connect(
53
+ db_uri,
54
+ uri=True,
55
+ check_same_thread=self.check_same_thread,
56
+ timeout=self.timeout,
57
+ )
58
+ else:
59
+ self.connection = await aiosqlite.connect(
60
+ db_path,
61
+ check_same_thread=self.check_same_thread,
62
+ timeout=self.timeout,
63
+ )
64
+
65
+ self.connection.row_factory = aiosqlite.Row # Return rows as dictionaries
66
+
67
+ # Configure database settings
68
+ if self.connection:
69
+ async with self.connection.cursor() as cursor:
70
+ if self.journal_mode:
71
+ await cursor.execute(f"PRAGMA journal_mode = {self.journal_mode}")
72
+ if self.foreign_keys:
73
+ await cursor.execute("PRAGMA foreign_keys = ON")
74
+ await self.connection.commit()
75
+
76
+ @asynccontextmanager
77
+ async def _get_cursor(self) -> AsyncIterator[aiosqlite.Cursor]:
78
+ """Context manager for database cursor.
79
+
80
+ Ensures proper cursor cleanup and error handling.
81
+
82
+ Yields
83
+ ------
84
+ aiosqlite.Cursor
85
+ Async SQLite database cursor
86
+
87
+ Raises
88
+ ------
89
+ RuntimeError
90
+ If database connection is not established
91
+ aiosqlite.Error
92
+ If database error occurs during operation
93
+ """
94
+ await self._ensure_database()
95
+ if self.connection is None:
96
+ raise RuntimeError("Database connection not established")
97
+
98
+ async with self.connection.cursor() as cursor:
99
+ try:
100
+ yield cursor
101
+ except aiosqlite.Error as e:
102
+ logger.error(f"Database error: {e}")
103
+ await self.connection.rollback()
104
+ raise
105
+
106
+ async def _get_all_tables(self) -> list[str]:
107
+ """Get all non-system tables from the database.
108
+
109
+ Returns
110
+ -------
111
+ List of table names
112
+ """
113
+ async with self._get_cursor() as cursor:
114
+ await cursor.execute(
115
+ """
116
+ SELECT name FROM sqlite_master
117
+ WHERE type='table' AND name NOT LIKE 'sqlite_%'
118
+ """
119
+ )
120
+ rows = await cursor.fetchall()
121
+ return [row[0] for row in rows]
122
+
123
+ def _validate_identifier(self, identifier: str, identifier_type: str = "table") -> bool:
124
+ """Validate a database identifier to prevent injection.
125
+
126
+ Parameters
127
+ ----------
128
+ identifier : str
129
+ The identifier to validate (table name, column name, etc.)
130
+ identifier_type : str
131
+ Type of identifier for error messages
132
+
133
+ Returns
134
+ -------
135
+ bool
136
+ True if valid, False otherwise
137
+ """
138
+ return validate_sql_identifier(identifier, identifier_type=identifier_type)
139
+
140
+ async def aget_table_schemas(self) -> dict[str, dict[str, Any]]:
141
+ """Get schema information for all tables.
142
+
143
+ Returns
144
+ -------
145
+ Dictionary mapping table names to schema information
146
+ """
147
+ tables = await self._get_all_tables()
148
+ schemas = {}
149
+
150
+ async with self._get_cursor() as cursor:
151
+ for table in tables:
152
+ if not self._validate_identifier(table):
153
+ continue
154
+
155
+ await cursor.execute(f'PRAGMA table_info("{table}")') # nosec B608 - validated
156
+ rows = await cursor.fetchall()
157
+
158
+ columns = {}
159
+ primary_keys = []
160
+ for row in rows:
161
+ col_name = row[1]
162
+ col_type = row[2]
163
+ is_pk = row[5]
164
+
165
+ columns[col_name] = col_type
166
+ if is_pk:
167
+ primary_keys.append(col_name)
168
+
169
+ # nosec B608 - validated
170
+ await cursor.execute(f'PRAGMA foreign_key_list("{table}")')
171
+ fk_rows = await cursor.fetchall()
172
+ foreign_keys = [
173
+ {
174
+ "from_column": fk_row[3],
175
+ "to_table": fk_row[2],
176
+ "to_column": fk_row[4],
177
+ }
178
+ for fk_row in fk_rows
179
+ ]
180
+
181
+ schemas[table] = {
182
+ "table_name": table,
183
+ "columns": columns,
184
+ "primary_keys": primary_keys,
185
+ "foreign_keys": foreign_keys,
186
+ }
187
+
188
+ return schemas
189
+
190
+ async def aexecute_query(
191
+ self, query: str, params: dict[str, Any] | None = None
192
+ ) -> list[dict[str, Any]]:
193
+ """Execute a SQL query and return results.
194
+
195
+ Args
196
+ ----
197
+ query: SQL query to execute
198
+ params: Optional query parameters for safe parameterized queries
199
+
200
+ Returns
201
+ -------
202
+ List of dictionaries representing query result rows.
203
+ For non-SELECT queries (INSERT/UPDATE/DELETE), returns an empty list
204
+ and commits the transaction.
205
+
206
+ Notes
207
+ -----
208
+ - SELECT/PRAGMA/WITH queries return result rows
209
+ - INSERT/UPDATE/DELETE queries are committed automatically and return []
210
+ - Use parameters to prevent SQL injection with :name placeholders
211
+
212
+ Raises
213
+ ------
214
+ ValueError
215
+ If a required parameter is missing
216
+ aiosqlite.Error
217
+ If query execution fails
218
+ """
219
+ try:
220
+ async with self._get_cursor() as cursor:
221
+ if params:
222
+ # SQLite uses ? placeholders, but we support :name format
223
+ param_names = []
224
+
225
+ def replacer(match: re.Match[str]) -> str:
226
+ param_names.append(match.group(1))
227
+ return "?"
228
+
229
+ converted_query = re.sub(r":(\w+)", replacer, query)
230
+
231
+ try:
232
+ param_values = [params[name] for name in param_names]
233
+ await cursor.execute(converted_query, param_values)
234
+ except KeyError as e:
235
+ raise ValueError(f"Missing parameter: {e}") from e
236
+ else:
237
+ await cursor.execute(query)
238
+
239
+ # Determine query type
240
+ query_type = query.strip().upper().split()[0] if query.strip() else ""
241
+
242
+ # For SELECT queries, fetch results
243
+ if query_type in ("SELECT", "PRAGMA", "WITH"):
244
+ rows = await cursor.fetchall()
245
+ return [dict(row) for row in rows]
246
+ # For INSERT/UPDATE/DELETE operations
247
+ # Note: If database is opened in read-only mode, SQLite will
248
+ # automatically raise an OperationalError for write operations
249
+ affected_rows = cursor.rowcount
250
+ if self.connection is not None:
251
+ await self.connection.commit()
252
+
253
+ # Log the operation
254
+ logger.info(f"Executed {query_type} query, affected rows: {affected_rows}")
255
+ return []
256
+ except aiosqlite.Error as e:
257
+ logger.error(f"Query execution failed: {e}")
258
+ logger.error(f"Query: {query[:100]}...") # Log first 100 chars of query
259
+ raise
260
+
261
+ async def aget_relationships(self) -> list[dict[str, Any]]:
262
+ """Get foreign key relationships between tables.
263
+
264
+ Returns
265
+ -------
266
+ List of relationship dictionaries
267
+ """
268
+ tables = await self._get_all_tables()
269
+ relationships: list[dict[str, Any]] = []
270
+
271
+ async with self._get_cursor() as cursor:
272
+ for table in tables:
273
+ if not self._validate_identifier(table):
274
+ continue
275
+
276
+ # nosec B608 - validated
277
+ await cursor.execute(f'PRAGMA foreign_key_list("{table}")')
278
+ fk_rows = await cursor.fetchall()
279
+
280
+ relationships.extend(
281
+ {
282
+ "from_table": table,
283
+ "from_column": fk_row[3],
284
+ "to_table": fk_row[2],
285
+ "to_column": fk_row[4],
286
+ "relationship_type": "many_to_one", # SQLite doesn't store this info
287
+ }
288
+ for fk_row in fk_rows
289
+ )
290
+
291
+ return relationships
292
+
293
+ async def aget_indexes(self) -> list[dict[str, Any]]:
294
+ """Get index information for performance optimization.
295
+
296
+ Returns
297
+ -------
298
+ List of index dictionaries
299
+ """
300
+ indexes = []
301
+
302
+ async with self._get_cursor() as cursor:
303
+ await cursor.execute(
304
+ """
305
+ SELECT name, tbl_name, sql FROM sqlite_master
306
+ WHERE type='index' AND sql IS NOT NULL
307
+ """
308
+ )
309
+ index_rows = await cursor.fetchall()
310
+
311
+ for row in index_rows:
312
+ index_name = row[0]
313
+ table_name = row[1]
314
+
315
+ if not self._validate_identifier(index_name, "index"):
316
+ continue
317
+ if not self._validate_identifier(table_name, "table"):
318
+ continue
319
+
320
+ await cursor.execute(f'PRAGMA index_info("{index_name}")') # nosec B608 - validated
321
+ col_rows = await cursor.fetchall()
322
+ columns = [col_row[2] for col_row in col_rows]
323
+
324
+ await cursor.execute(f'PRAGMA index_list("{table_name}")') # nosec B608 - validated
325
+ idx_list = await cursor.fetchall()
326
+ is_unique = False
327
+ for idx in idx_list:
328
+ if idx[1] == index_name:
329
+ is_unique = bool(idx[2])
330
+ break
331
+
332
+ indexes.append({
333
+ "index_name": index_name,
334
+ "table_name": table_name,
335
+ "columns": columns,
336
+ "index_type": "btree", # SQLite primarily uses B-tree
337
+ "is_unique": is_unique,
338
+ })
339
+
340
+ return indexes
341
+
342
+ async def aget_table_statistics(self) -> dict[str, dict[str, Any]]:
343
+ """Get table statistics for query optimization.
344
+
345
+ Returns
346
+ -------
347
+ Dictionary mapping table names to statistics
348
+ """
349
+ tables = await self._get_all_tables()
350
+ stats = {}
351
+
352
+ async with self._get_cursor() as cursor:
353
+ for table in tables:
354
+ if not self._validate_identifier(table):
355
+ continue # Skip invalid table names
356
+
357
+ # SQLite uses double quotes for identifiers
358
+ await cursor.execute(f'SELECT COUNT(*) FROM "{table}"') # nosec B608 - validated
359
+ result = await cursor.fetchone()
360
+ row_count = result[0] if result else 0
361
+
362
+ await cursor.execute(
363
+ "SELECT SUM(LENGTH(sql)) FROM sqlite_master WHERE tbl_name = ?",
364
+ (table,),
365
+ )
366
+ size_result = await cursor.fetchone()
367
+ size_bytes = size_result[0] if size_result and size_result[0] else 0
368
+
369
+ stats[table] = {
370
+ "row_count": row_count,
371
+ "size_bytes": size_bytes,
372
+ "last_updated": None, # SQLite doesn't track this
373
+ }
374
+
375
+ return stats
376
+
377
+ async def close(self) -> None:
378
+ """Close database connection."""
379
+ if self.connection:
380
+ await self.connection.close()
381
+ self.connection = None
382
+
383
+ async def __aenter__(self) -> "SQLiteAdapter":
384
+ """Async context manager entry."""
385
+ await self._ensure_database()
386
+ return self
387
+
388
+ async def __aexit__(
389
+ self,
390
+ _exc_type: Any, # noqa: ARG002
391
+ _exc_val: Any, # noqa: ARG002
392
+ _exc_tb: Any, # noqa: ARG002
393
+ ) -> None:
394
+ """Async context manager exit."""
395
+ await self.close()
396
+
397
+ def __repr__(self) -> str:
398
+ """Return string representation."""
399
+ mode = "read-only" if self.read_only else "read-write"
400
+ return f"SQLiteAdapter(db_path='{self.db_path}', mode='{mode}')"
401
+
402
+ # SupportsReadOnly protocol method
403
+ async def is_read_only(self) -> bool:
404
+ """Check if the adapter is in read-only mode.
405
+
406
+ Returns
407
+ -------
408
+ True if adapter is read-only, False otherwise
409
+ """
410
+ return bool(self.read_only)