datus-mysql 0.1.0__tar.gz

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.
@@ -0,0 +1,137 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ pip-wheel-metadata/
24
+ share/python-wheels/
25
+ *.egg-info/
26
+ .installed.cfg
27
+ *.egg
28
+ MANIFEST
29
+
30
+ # PyInstaller
31
+ *.manifest
32
+ *.spec
33
+
34
+ # Installer logs
35
+ pip-log.txt
36
+ pip-delete-this-directory.txt
37
+
38
+ # Unit test / coverage reports
39
+ htmlcov/
40
+ .tox/
41
+ .nox/
42
+ .coverage
43
+ .coverage.*
44
+ .cache
45
+ nosetests.xml
46
+ coverage.xml
47
+ *.cover
48
+ *.py,cover
49
+ .hypothesis/
50
+ .pytest_cache/
51
+
52
+ # Translations
53
+ *.mo
54
+ *.pot
55
+
56
+ # Django stuff:
57
+ *.log
58
+ local_settings.py
59
+ db.sqlite3
60
+ db.sqlite3-journal
61
+
62
+ # Flask stuff:
63
+ instance/
64
+ .webassets-cache
65
+
66
+ # Scrapy stuff:
67
+ .scrapy
68
+
69
+ # Sphinx documentation
70
+ docs/_build/
71
+
72
+ # PyBuilder
73
+ target/
74
+
75
+ # Jupyter Notebook
76
+ .ipynb_checkpoints
77
+
78
+ # IPython
79
+ profile_default/
80
+ ipython_config.py
81
+
82
+ # pyenv
83
+ .python-version
84
+
85
+ # pipenv
86
+ Pipfile.lock
87
+
88
+ # uv
89
+ uv.lock
90
+
91
+ # PEP 582
92
+ __pypackages__/
93
+
94
+ # Celery stuff
95
+ celerybeat-schedule
96
+ celerybeat.pid
97
+
98
+ # SageMath parsed files
99
+ *.sage.py
100
+
101
+ # Environments
102
+ .env
103
+ .venv
104
+ env/
105
+ venv/
106
+ ENV/
107
+ env.bak/
108
+ venv.bak/
109
+
110
+ # Spyder project settings
111
+ .spyderproject
112
+ .spyproject
113
+
114
+ # Rope project settings
115
+ .ropeproject
116
+
117
+ # mkdocs documentation
118
+ /site
119
+
120
+ # mypy
121
+ .mypy_cache/
122
+ .dmypy.json
123
+ dmypy.json
124
+
125
+ # Pyre type checker
126
+ .pyre/
127
+
128
+ # IDEs
129
+ .vscode/
130
+ .idea/
131
+ *.swp
132
+ *.swo
133
+ *~
134
+
135
+ # OS
136
+ .DS_Store
137
+ Thumbs.db
@@ -0,0 +1,102 @@
1
+ Metadata-Version: 2.4
2
+ Name: datus-mysql
3
+ Version: 0.1.0
4
+ Summary: MySQL database adapter for Datus
5
+ Project-URL: Homepage, https://github.com/Datus-ai/datus-db-adapters
6
+ Project-URL: Repository, https://github.com/Datus-ai/datus-db-adapters
7
+ Project-URL: Issues, https://github.com/Datus-ai/datus-db-adapters/issues
8
+ Author-email: DatusAI <support@datus.ai>
9
+ License: Apache-2.0
10
+ Keywords: adapter,database,datus,mysql
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Requires-Python: >=3.12
17
+ Requires-Dist: datus-agent>0.2.1
18
+ Requires-Dist: datus-sqlalchemy>=0.1.0
19
+ Requires-Dist: pymysql>=1.1.1
20
+ Description-Content-Type: text/markdown
21
+
22
+ # datus-mysql
23
+
24
+ MySQL database adapter for Datus.
25
+
26
+ ## Installation
27
+
28
+ ```bash
29
+ pip install datus-mysql
30
+ ```
31
+
32
+ This will automatically install the required dependencies:
33
+ - `datus-agent`
34
+ - `datus-sqlalchemy`
35
+ - `pymysql`
36
+
37
+ ## Usage
38
+
39
+ The adapter is automatically registered with Datus when installed. Configure your database connection in your Datus configuration:
40
+
41
+ ```yaml
42
+ database:
43
+ type: mysql
44
+ host: localhost
45
+ port: 3306
46
+ username: root
47
+ password: your_password
48
+ database: your_database
49
+ ```
50
+
51
+ Or use programmatically:
52
+
53
+ ```python
54
+ from datus_mysql import MySQLConnector
55
+
56
+ # Create connector
57
+ connector = MySQLConnector(
58
+ host="localhost",
59
+ port=3306,
60
+ user="root",
61
+ password="your_password",
62
+ database="mydb"
63
+ )
64
+
65
+ # Test connection
66
+ connector.test_connection()
67
+
68
+ # Execute query
69
+ result = connector.execute_query("SELECT * FROM users LIMIT 10")
70
+ print(result.sql_return)
71
+
72
+ # Get table list
73
+ tables = connector.get_tables()
74
+ print(f"Tables: {tables}")
75
+
76
+ # Get table schema
77
+ schema = connector.get_schema(table_name="users")
78
+ for column in schema:
79
+ print(f"{column['name']}: {column['type']}")
80
+ ```
81
+
82
+ ## Features
83
+
84
+ - Full CRUD operations (SELECT, INSERT, UPDATE, DELETE)
85
+ - DDL execution (CREATE, ALTER, DROP)
86
+ - Metadata retrieval (tables, views, schemas)
87
+ - Sample data extraction
88
+ - Multiple result formats (pandas, arrow, csv, list)
89
+ - Connection pooling and management
90
+ - Comprehensive error handling
91
+
92
+ ## Requirements
93
+
94
+ - Python >= 3.10
95
+ - MySQL >= 5.7 or MariaDB >= 10.2
96
+ - datus-agent >= 0.3.0
97
+ - datus-sqlalchemy >= 0.1.0
98
+ - pymysql >= 1.0.0
99
+
100
+ ## License
101
+
102
+ Apache License 2.0
@@ -0,0 +1,81 @@
1
+ # datus-mysql
2
+
3
+ MySQL database adapter for Datus.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install datus-mysql
9
+ ```
10
+
11
+ This will automatically install the required dependencies:
12
+ - `datus-agent`
13
+ - `datus-sqlalchemy`
14
+ - `pymysql`
15
+
16
+ ## Usage
17
+
18
+ The adapter is automatically registered with Datus when installed. Configure your database connection in your Datus configuration:
19
+
20
+ ```yaml
21
+ database:
22
+ type: mysql
23
+ host: localhost
24
+ port: 3306
25
+ username: root
26
+ password: your_password
27
+ database: your_database
28
+ ```
29
+
30
+ Or use programmatically:
31
+
32
+ ```python
33
+ from datus_mysql import MySQLConnector
34
+
35
+ # Create connector
36
+ connector = MySQLConnector(
37
+ host="localhost",
38
+ port=3306,
39
+ user="root",
40
+ password="your_password",
41
+ database="mydb"
42
+ )
43
+
44
+ # Test connection
45
+ connector.test_connection()
46
+
47
+ # Execute query
48
+ result = connector.execute_query("SELECT * FROM users LIMIT 10")
49
+ print(result.sql_return)
50
+
51
+ # Get table list
52
+ tables = connector.get_tables()
53
+ print(f"Tables: {tables}")
54
+
55
+ # Get table schema
56
+ schema = connector.get_schema(table_name="users")
57
+ for column in schema:
58
+ print(f"{column['name']}: {column['type']}")
59
+ ```
60
+
61
+ ## Features
62
+
63
+ - Full CRUD operations (SELECT, INSERT, UPDATE, DELETE)
64
+ - DDL execution (CREATE, ALTER, DROP)
65
+ - Metadata retrieval (tables, views, schemas)
66
+ - Sample data extraction
67
+ - Multiple result formats (pandas, arrow, csv, list)
68
+ - Connection pooling and management
69
+ - Comprehensive error handling
70
+
71
+ ## Requirements
72
+
73
+ - Python >= 3.10
74
+ - MySQL >= 5.7 or MariaDB >= 10.2
75
+ - datus-agent >= 0.3.0
76
+ - datus-sqlalchemy >= 0.1.0
77
+ - pymysql >= 1.0.0
78
+
79
+ ## License
80
+
81
+ Apache License 2.0
@@ -0,0 +1,16 @@
1
+ # Copyright 2025-present DatusAI, Inc.
2
+ # Licensed under the Apache License, Version 2.0.
3
+ # See http://www.apache.org/licenses/LICENSE-2.0 for details.
4
+
5
+ from .config import MySQLConfig
6
+ from .connector import MySQLConnector
7
+
8
+ __version__ = "0.1.0"
9
+ __all__ = ["MySQLConnector", "MySQLConfig", "register"]
10
+
11
+
12
+ def register():
13
+ """Register MySQL connector with Datus registry."""
14
+ from datus.tools.db_tools import connector_registry
15
+
16
+ connector_registry.register("mysql", MySQLConnector)
@@ -0,0 +1,22 @@
1
+ # Copyright 2025-present DatusAI, Inc.
2
+ # Licensed under the Apache License, Version 2.0.
3
+ # See http://www.apache.org/licenses/LICENSE-2.0 for details.
4
+
5
+ from typing import Optional
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+
10
+ class MySQLConfig(BaseModel):
11
+ """MySQL-specific configuration."""
12
+
13
+ model_config = ConfigDict(extra="forbid")
14
+
15
+ host: str = Field(..., description="MySQL server host")
16
+ port: int = Field(default=3306, description="MySQL server port")
17
+ username: str = Field(..., description="MySQL username")
18
+ password: str = Field(default="", description="MySQL password")
19
+ database: Optional[str] = Field(default=None, description="Default database name")
20
+ charset: str = Field(default="utf8mb4", description="Character set to use")
21
+ autocommit: bool = Field(default=True, description="Enable autocommit mode")
22
+ timeout_seconds: int = Field(default=30, description="Connection timeout in seconds")
@@ -0,0 +1,398 @@
1
+ # Copyright 2025-present DatusAI, Inc.
2
+ # Licensed under the Apache License, Version 2.0.
3
+ # See http://www.apache.org/licenses/LICENSE-2.0 for details.
4
+
5
+ from typing import Any, Dict, List, Optional, Set, Union, override
6
+ from urllib.parse import quote_plus
7
+
8
+ from datus.schemas.base import TABLE_TYPE
9
+ from datus.tools.db_tools.base import list_to_in_str
10
+ from datus.utils.constants import DBType
11
+ from datus.utils.exceptions import DatusException, ErrorCode
12
+ from datus.utils.loggings import get_logger
13
+ from datus_sqlalchemy import SQLAlchemyConnector
14
+ from pydantic import BaseModel, Field
15
+ from sqlalchemy import text
16
+
17
+ from .config import MySQLConfig
18
+
19
+ logger = get_logger(__name__)
20
+
21
+
22
+ class TableMetadataNames(BaseModel):
23
+ """Metadata configuration for different MySQL object types."""
24
+
25
+ show_table: str = Field(..., description="SHOW command keyword")
26
+ show_create_table: str = Field(..., description="SHOW CREATE command keyword")
27
+ info_table: str = Field(..., description="INFORMATION_SCHEMA table name")
28
+ table_types: Optional[List[str]] = Field(default=None, description="TABLE_TYPE values in INFORMATION_SCHEMA")
29
+
30
+
31
+ # Metadata configuration for MySQL objects
32
+ METADATA_DICT: Dict[TABLE_TYPE, TableMetadataNames] = {
33
+ "table": TableMetadataNames(
34
+ show_table="TABLES", show_create_table="TABLE", info_table="TABLES", table_types=["TABLE", "BASE TABLE"]
35
+ ),
36
+ "view": TableMetadataNames(
37
+ show_table="VIEWS",
38
+ show_create_table="VIEW",
39
+ info_table="VIEWS",
40
+ ),
41
+ "mv": TableMetadataNames(
42
+ show_table="MATERIALIZED VIEWS",
43
+ show_create_table="MATERIALIZED VIEW",
44
+ info_table="MATERIALIZED_VIEWS",
45
+ ),
46
+ }
47
+
48
+
49
+ def _get_metadata_config(table_type: TABLE_TYPE) -> TableMetadataNames:
50
+ """Get metadata configuration for given table type."""
51
+ if table_type not in METADATA_DICT:
52
+ raise DatusException(ErrorCode.COMMON_FIELD_INVALID, f"Invalid table type '{table_type}'")
53
+ return METADATA_DICT[table_type]
54
+
55
+
56
+ class MySQLConnector(SQLAlchemyConnector):
57
+ """MySQL database connector."""
58
+
59
+ def __init__(self, config: Union[MySQLConfig, dict]):
60
+ """
61
+ Initialize MySQL connector.
62
+
63
+ Args:
64
+ config: MySQLConfig object or dict with configuration
65
+ """
66
+ # Handle config object or dict
67
+ if isinstance(config, dict):
68
+ config = MySQLConfig(**config)
69
+ elif not isinstance(config, MySQLConfig):
70
+ raise TypeError(f"config must be MySQLConfig or dict, got {type(config)}")
71
+
72
+ self.config = config
73
+ self.host = config.host
74
+ self.port = config.port
75
+ self.username = config.username
76
+ self.password = config.password
77
+ database = config.database or ""
78
+
79
+ # URL encode password to handle special characters
80
+ encoded_password = quote_plus(self.password) if self.password else ""
81
+
82
+ # Build connection string
83
+ connection_string = (
84
+ f"mysql+pymysql://{self.username}:{encoded_password}@{self.host}:{self.port}/"
85
+ f"{database}?charset={config.charset}&autocommit={'true' if config.autocommit else 'false'}"
86
+ )
87
+
88
+ super().__init__(connection_string, dialect=DBType.MYSQL)
89
+ self.database_name = database
90
+
91
+ # ==================== System Resources ====================
92
+
93
+ @override
94
+ def _sys_databases(self) -> Set[str]:
95
+ """System databases to filter out."""
96
+ return {"sys", "information_schema", "performance_schema", "mysql"}
97
+
98
+ @override
99
+ def _sys_schemas(self) -> Set[str]:
100
+ """System schemas to filter out (same as databases for MySQL)."""
101
+ return self._sys_databases()
102
+
103
+ # ==================== Utility Methods ====================
104
+
105
+ @staticmethod
106
+ def _quote_identifier(identifier: str) -> str:
107
+ """Safely wrap identifiers with backticks for MySQL-compatible dialects."""
108
+ escaped = identifier.replace("`", "``")
109
+ return f"`{escaped}`"
110
+
111
+ # ==================== Metadata Retrieval ====================
112
+
113
+ def _get_metadata(
114
+ self,
115
+ table_type: TABLE_TYPE = "table",
116
+ catalog_name: str = "",
117
+ database_name: str = "",
118
+ ) -> List[Dict[str, str]]:
119
+ """
120
+ Get metadata for tables/views from INFORMATION_SCHEMA.
121
+
122
+ Args:
123
+ table_type: Type of object (table, view, mv)
124
+ catalog_name: Catalog name (unused in MySQL)
125
+ database_name: Database name to query
126
+
127
+ Returns:
128
+ List of metadata dictionaries
129
+ """
130
+ self.connect()
131
+ database_name = database_name or self.database_name
132
+
133
+ # Build WHERE clause
134
+ if database_name:
135
+ where = f"TABLE_SCHEMA = '{database_name}'"
136
+ else:
137
+ where = f"{list_to_in_str('TABLE_SCHEMA not in', list(self._sys_databases()))}"
138
+
139
+ # Get metadata configuration
140
+ metadata_config = _get_metadata_config(table_type)
141
+
142
+ # Build and execute query
143
+ type_filter = list_to_in_str("and TABLE_TYPE in ", metadata_config.table_types)
144
+ query = (
145
+ f"SELECT TABLE_SCHEMA, TABLE_NAME "
146
+ f"FROM information_schema.{metadata_config.info_table} "
147
+ f"WHERE {where} {type_filter}"
148
+ )
149
+
150
+ query_result = self._execute_pandas(query)
151
+
152
+ # Format results
153
+ result = []
154
+ for i in range(len(query_result)):
155
+ db_name = query_result["TABLE_SCHEMA"][i]
156
+ tb_name = query_result["TABLE_NAME"][i]
157
+ result.append(
158
+ {
159
+ "identifier": self.identifier(database_name=db_name, table_name=tb_name),
160
+ "catalog_name": "",
161
+ "schema_name": "",
162
+ "database_name": db_name,
163
+ "table_name": tb_name,
164
+ "table_type": table_type,
165
+ }
166
+ )
167
+ return result
168
+
169
+ def _show_create(self, full_name: str, create_type: str) -> str:
170
+ """
171
+ Execute SHOW CREATE statement to get DDL.
172
+
173
+ Args:
174
+ full_name: Fully-qualified table name
175
+ create_type: Object type (TABLE, VIEW, etc.)
176
+
177
+ Returns:
178
+ DDL statement as string
179
+ """
180
+ sql = f"SHOW CREATE {create_type} {full_name}"
181
+ ddl_result = self._execute_pandas(sql)
182
+ if not ddl_result.empty and len(ddl_result.columns) >= 2:
183
+ return str(ddl_result.iloc[0, 1])
184
+ return f"-- DDL not available for {full_name}"
185
+
186
+ def _get_objects_with_ddl(
187
+ self,
188
+ table_type: TABLE_TYPE = "table",
189
+ tables: Optional[List[str]] = None,
190
+ catalog_name: str = "",
191
+ database_name: str = "",
192
+ ) -> List[Dict[str, str]]:
193
+ """
194
+ Get metadata with DDL statements.
195
+
196
+ Args:
197
+ table_type: Type of object
198
+ tables: Optional list of specific tables to retrieve
199
+ catalog_name: Catalog name (unused)
200
+ database_name: Database name
201
+
202
+ Returns:
203
+ List of metadata dictionaries with DDL
204
+ """
205
+ result = []
206
+ filter_tables = self._reset_filter_tables(tables, catalog_name, database_name)
207
+ metadata_config = _get_metadata_config(table_type)
208
+
209
+ for meta in self._get_metadata(table_type, catalog_name, database_name):
210
+ full_name = self.full_name(database_name=meta["database_name"], table_name=meta["table_name"])
211
+
212
+ # Skip if not in filter list
213
+ if filter_tables and full_name not in filter_tables:
214
+ continue
215
+
216
+ # Get DDL
217
+ try:
218
+ ddl = self._show_create(full_name, metadata_config.show_create_table)
219
+ except Exception as e:
220
+ logger.warning(f"Could not get DDL for {full_name}: {e}")
221
+ ddl = f"-- DDL not available for {meta['table_name']}"
222
+
223
+ meta["definition"] = ddl
224
+ result.append(meta)
225
+
226
+ return result
227
+
228
+ @override
229
+ def get_tables(self, catalog_name: str = "", database_name: str = "", schema_name: str = "") -> List[str]:
230
+ """Get list of table names."""
231
+ return [meta["table_name"] for meta in self._get_metadata("table", catalog_name, database_name)]
232
+
233
+ @override
234
+ def get_tables_with_ddl(
235
+ self, catalog_name: str = "", database_name: str = "", schema_name: str = "", tables: Optional[List[str]] = None
236
+ ) -> List[Dict[str, str]]:
237
+ """Get tables with DDL statements."""
238
+ return self._get_objects_with_ddl("table", tables, catalog_name, database_name)
239
+
240
+ @override
241
+ def get_views_with_ddl(
242
+ self, catalog_name: str = "", database_name: str = "", schema_name: str = ""
243
+ ) -> List[Dict[str, str]]:
244
+ """Get views with DDL statements."""
245
+ return self._get_objects_with_ddl("view", None, catalog_name, database_name)
246
+
247
+ @override
248
+ def get_schema(
249
+ self, catalog_name: str = "", database_name: str = "", schema_name: str = "", table_name: str = ""
250
+ ) -> List[Dict[str, Any]]:
251
+ """
252
+ Get table schema using DESCRIBE.
253
+
254
+ Args:
255
+ catalog_name: Catalog name (unused)
256
+ database_name: Database name
257
+ schema_name: Schema name (unused)
258
+ table_name: Table name
259
+
260
+ Returns:
261
+ List of column information dictionaries
262
+ """
263
+ if not table_name:
264
+ return []
265
+
266
+ database_name = database_name or self.database_name
267
+ full_table_name = self.full_name(database_name=database_name, table_name=table_name)
268
+
269
+ # Use DESCRIBE to get schema
270
+ sql = f"DESCRIBE {full_table_name}"
271
+ query_result = self._execute_pandas(sql)
272
+
273
+ result = []
274
+ for i in range(len(query_result)):
275
+ result.append(
276
+ {
277
+ "cid": i,
278
+ "name": query_result["Field"][i],
279
+ "type": query_result["Type"][i],
280
+ "nullable": query_result["Null"][i] == "YES",
281
+ "default_value": query_result["Default"][i],
282
+ "pk": query_result["Key"][i] == "PRI",
283
+ }
284
+ )
285
+ return result
286
+
287
+ # ==================== Database/Schema Management ====================
288
+
289
+ @override
290
+ def get_databases(self, catalog_name: str = "", include_sys: bool = False) -> List[str]:
291
+ """Get list of databases (MySQL uses schemas as databases)."""
292
+ return super().get_schemas(catalog_name=catalog_name, include_sys=include_sys)
293
+
294
+ @override
295
+ def get_schemas(self, catalog_name: str = "", database_name: str = "", include_sys: bool = False) -> List[str]:
296
+ """MySQL doesn't have separate schemas, return empty list."""
297
+ return []
298
+
299
+ @override
300
+ def _sqlalchemy_schema(
301
+ self, catalog_name: str = "", database_name: str = "", schema_name: str = ""
302
+ ) -> Optional[str]:
303
+ """Get schema name for SQLAlchemy Inspector (database name in MySQL)."""
304
+ return database_name or self.database_name
305
+
306
+ @override
307
+ def do_switch_context(self, catalog_name: str = "", database_name: str = "", schema_name: str = ""):
308
+ """Switch database context using USE statement."""
309
+ if database_name:
310
+ self.connection.execute(text(f"USE {self._quote_identifier(database_name)}"))
311
+
312
+ # ==================== Sample Data ====================
313
+
314
+ def get_sample_rows(
315
+ self,
316
+ tables: Optional[List[str]] = None,
317
+ top_n: int = 5,
318
+ catalog_name: str = "",
319
+ database_name: str = "",
320
+ schema_name: str = "",
321
+ table_type: TABLE_TYPE = "table",
322
+ ) -> List[Dict[str, str]]:
323
+ """Get sample rows from tables."""
324
+ # Delegate to base class for unsupported table types (e.g., "full")
325
+ if table_type == "full" or table_type not in METADATA_DICT:
326
+ return super().get_sample_rows(
327
+ tables=tables,
328
+ top_n=top_n,
329
+ catalog_name=catalog_name,
330
+ database_name=database_name,
331
+ schema_name=schema_name,
332
+ table_type=table_type,
333
+ )
334
+
335
+ self.connect()
336
+ database_name = database_name or self.database_name
337
+ result = []
338
+
339
+ # If specific tables provided, query those
340
+ if tables:
341
+ for table_name in tables:
342
+ full_name = self.full_name(
343
+ catalog_name=catalog_name, database_name=database_name, table_name=table_name
344
+ )
345
+ sql = f"SELECT * FROM {full_name} LIMIT {top_n}"
346
+ df = self._execute_pandas(sql)
347
+ if not df.empty:
348
+ result.append(
349
+ {
350
+ "identifier": self.identifier(
351
+ catalog_name=catalog_name, database_name=database_name, table_name=table_name
352
+ ),
353
+ "catalog_name": catalog_name,
354
+ "database_name": database_name,
355
+ "schema_name": "",
356
+ "table_name": table_name,
357
+ "sample_rows": df.to_csv(index=False),
358
+ }
359
+ )
360
+ return result
361
+
362
+ # Otherwise get metadata and query all tables
363
+ metadata = self._get_metadata(table_type, "", database_name)
364
+ for meta in metadata:
365
+ full_name = self.full_name(database_name=meta["database_name"], table_name=meta["table_name"])
366
+ sql = f"SELECT * FROM {full_name} LIMIT {top_n}"
367
+ df = self._execute_pandas(sql)
368
+ if not df.empty:
369
+ result.append(
370
+ {
371
+ "identifier": meta["identifier"],
372
+ "catalog_name": meta["catalog_name"],
373
+ "database_name": meta["database_name"],
374
+ "schema_name": "",
375
+ "table_name": meta["table_name"],
376
+ "sample_rows": df.to_csv(index=False),
377
+ }
378
+ )
379
+ return result
380
+
381
+ # ==================== Utility Methods ====================
382
+
383
+ @override
384
+ def full_name(
385
+ self, catalog_name: str = "", database_name: str = "", schema_name: str = "", table_name: str = ""
386
+ ) -> str:
387
+ """Build fully-qualified table name."""
388
+ if database_name:
389
+ return f"`{database_name}`.`{table_name}`"
390
+ return f"`{table_name}`"
391
+
392
+ @override
393
+ def _reset_filter_tables(
394
+ self, tables: Optional[List[str]] = None, catalog_name: str = "", database_name: str = "", schema_name: str = ""
395
+ ) -> List[str]:
396
+ """Reset filter tables with full names."""
397
+ database_name = database_name or self.database_name
398
+ return super()._reset_filter_tables(tables, "", database_name, "")
@@ -0,0 +1,42 @@
1
+ [project]
2
+ name = "datus-mysql"
3
+ version = "0.1.0"
4
+ description = "MySQL database adapter for Datus"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = {text = "Apache-2.0"}
8
+ authors = [
9
+ {name = "DatusAI", email = "support@datus.ai"}
10
+ ]
11
+ keywords = ["datus", "database", "mysql", "adapter"]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: Apache Software License",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.12",
18
+ ]
19
+
20
+ dependencies = [
21
+ "datus-agent>0.2.1",
22
+ "datus-sqlalchemy>=0.1.0",
23
+ "pymysql>=1.1.1",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/Datus-ai/datus-db-adapters"
28
+ Repository = "https://github.com/Datus-ai/datus-db-adapters"
29
+ Issues = "https://github.com/Datus-ai/datus-db-adapters/issues"
30
+
31
+ [project.entry-points."datus.adapters"]
32
+ mysql = "datus_mysql:register"
33
+
34
+ [tool.uv.sources]
35
+ datus-sqlalchemy = { workspace = true }
36
+
37
+ [build-system]
38
+ requires = ["hatchling"]
39
+ build-backend = "hatchling.build"
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["datus_mysql"]
File without changes
@@ -0,0 +1,400 @@
1
+ # Copyright 2025-present DatusAI, Inc.
2
+ # Licensed under the Apache License, Version 2.0.
3
+ # See http://www.apache.org/licenses/LICENSE-2.0 for details.
4
+
5
+ import os
6
+ import uuid
7
+ from typing import Generator
8
+
9
+ import pytest
10
+ from datus.utils.exceptions import DatusException
11
+ from datus_mysql import MySQLConfig, MySQLConnector
12
+
13
+
14
+ @pytest.fixture
15
+ def config() -> MySQLConfig:
16
+ """Create MySQL configuration from environment or defaults."""
17
+ return MySQLConfig(
18
+ host=os.getenv("MYSQL_HOST", "localhost"),
19
+ port=int(os.getenv("MYSQL_PORT", "3306")),
20
+ username=os.getenv("MYSQL_USER", "root"),
21
+ password=os.getenv("MYSQL_PASSWORD", ""),
22
+ database=os.getenv("MYSQL_DATABASE", "test"),
23
+ )
24
+
25
+
26
+ @pytest.fixture
27
+ def connector(config: MySQLConfig) -> Generator[MySQLConnector, None, None]:
28
+ """Create and cleanup MySQL connector."""
29
+ conn = MySQLConnector(config)
30
+ yield conn
31
+ conn.close()
32
+
33
+
34
+ # ==================== Connection Tests ====================
35
+
36
+
37
+ def test_connection_with_config_object(config: MySQLConfig):
38
+ """Test connection using config object."""
39
+ conn = MySQLConnector(config)
40
+ assert conn.test_connection()
41
+ conn.close()
42
+
43
+
44
+ def test_connection_with_dict():
45
+ """Test connection using dict config."""
46
+ conn = MySQLConnector(
47
+ {
48
+ "host": os.getenv("MYSQL_HOST", "localhost"),
49
+ "port": int(os.getenv("MYSQL_PORT", "3306")),
50
+ "username": os.getenv("MYSQL_USER", "root"),
51
+ "password": os.getenv("MYSQL_PASSWORD", ""),
52
+ }
53
+ )
54
+ assert conn.test_connection()
55
+ conn.close()
56
+
57
+
58
+ # ==================== Database Tests ====================
59
+
60
+
61
+ def test_get_databases(connector: MySQLConnector):
62
+ """Test getting list of databases."""
63
+ databases = connector.get_databases()
64
+ assert isinstance(databases, list)
65
+ assert len(databases) > 0
66
+
67
+
68
+ def test_get_databases_exclude_system(connector: MySQLConnector):
69
+ """Test that system databases are excluded by default."""
70
+ databases = connector.get_databases(include_sys=False)
71
+ system_dbs = {"sys", "information_schema", "performance_schema", "mysql"}
72
+ for db in databases:
73
+ assert db not in system_dbs
74
+
75
+
76
+ # ==================== Table Metadata Tests ====================
77
+
78
+
79
+ def test_get_tables(connector: MySQLConnector, config: MySQLConfig):
80
+ """Test getting table list."""
81
+ tables = connector.get_tables(database_name=config.database)
82
+ assert isinstance(tables, list)
83
+
84
+
85
+ def test_get_tables_with_ddl(connector: MySQLConnector, config: MySQLConfig):
86
+ """Test getting tables with DDL."""
87
+ # Create a test table first
88
+ suffix = uuid.uuid4().hex[:8]
89
+ table_name = f"test_table_{suffix}"
90
+
91
+ connector.switch_context(database_name=config.database)
92
+ connector.execute_ddl(
93
+ f"""
94
+ CREATE TABLE IF NOT EXISTS {table_name} (
95
+ id INT PRIMARY KEY,
96
+ name VARCHAR(50)
97
+ )
98
+ """
99
+ )
100
+
101
+ try:
102
+ tables = connector.get_tables_with_ddl(database_name=config.database, tables=[table_name])
103
+
104
+ if len(tables) > 0:
105
+ table = tables[0]
106
+ assert "table_name" in table
107
+ assert "definition" in table
108
+ assert table["table_type"] == "table"
109
+ assert "database_name" in table
110
+ assert table["schema_name"] == ""
111
+ assert "identifier" in table
112
+ finally:
113
+ connector.execute_ddl(f"DROP TABLE IF EXISTS {table_name}")
114
+
115
+
116
+ # ==================== View Tests ====================
117
+
118
+
119
+ def test_get_views(connector: MySQLConnector, config: MySQLConfig):
120
+ """Test getting view list."""
121
+ views = connector.get_views(database_name=config.database)
122
+ assert isinstance(views, list)
123
+
124
+
125
+ def test_get_views_with_ddl(connector: MySQLConnector, config: MySQLConfig):
126
+ """Test getting views with DDL."""
127
+ # Create a test view first
128
+ suffix = uuid.uuid4().hex[:8]
129
+ view_name = f"test_view_{suffix}"
130
+ table_name = f"test_table_{suffix}"
131
+
132
+ connector.switch_context(database_name=config.database)
133
+
134
+ # Create base table
135
+ connector.execute_ddl(
136
+ f"""
137
+ CREATE TABLE IF NOT EXISTS {table_name} (
138
+ id INT PRIMARY KEY,
139
+ name VARCHAR(50)
140
+ )
141
+ """
142
+ )
143
+
144
+ # Create view
145
+ connector.execute_ddl(f"CREATE VIEW {view_name} AS SELECT * FROM {table_name}")
146
+
147
+ try:
148
+ views = connector.get_views_with_ddl(database_name=config.database)
149
+
150
+ if len(views) > 0:
151
+ view = [v for v in views if v["table_name"] == view_name]
152
+ if view:
153
+ assert "definition" in view[0]
154
+ assert view[0]["table_type"] == "view"
155
+ finally:
156
+ connector.execute_ddl(f"DROP VIEW IF EXISTS {view_name}")
157
+ connector.execute_ddl(f"DROP TABLE IF EXISTS {table_name}")
158
+
159
+
160
+ # ==================== Schema Tests ====================
161
+
162
+
163
+ def test_get_schema(connector: MySQLConnector, config: MySQLConfig):
164
+ """Test getting table schema."""
165
+ suffix = uuid.uuid4().hex[:8]
166
+ table_name = f"test_schema_{suffix}"
167
+
168
+ connector.switch_context(database_name=config.database)
169
+ connector.execute_ddl(
170
+ f"""
171
+ CREATE TABLE IF NOT EXISTS {table_name} (
172
+ id INT PRIMARY KEY AUTO_INCREMENT,
173
+ name VARCHAR(50) NOT NULL,
174
+ email VARCHAR(100),
175
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
176
+ )
177
+ """
178
+ )
179
+
180
+ try:
181
+ schema = connector.get_schema(database_name=config.database, table_name=table_name)
182
+
183
+ assert len(schema) == 4
184
+
185
+ # Check id column
186
+ id_col = [col for col in schema if col["name"] == "id"][0]
187
+ assert id_col["pk"] is True
188
+ assert "int" in id_col["type"].lower()
189
+
190
+ # Check name column
191
+ name_col = [col for col in schema if col["name"] == "name"][0]
192
+ assert name_col["nullable"] is False
193
+ assert "varchar" in name_col["type"].lower()
194
+ finally:
195
+ connector.execute_ddl(f"DROP TABLE IF EXISTS {table_name}")
196
+
197
+
198
+ # ==================== Sample Data Tests ====================
199
+
200
+
201
+ def test_get_sample_rows(connector: MySQLConnector, config: MySQLConfig):
202
+ """Test getting sample rows."""
203
+ suffix = uuid.uuid4().hex[:8]
204
+ table_name = f"test_sample_{suffix}"
205
+
206
+ connector.switch_context(database_name=config.database)
207
+ connector.execute_ddl(
208
+ f"""
209
+ CREATE TABLE IF NOT EXISTS {table_name} (
210
+ id INT PRIMARY KEY,
211
+ name VARCHAR(50)
212
+ )
213
+ """
214
+ )
215
+
216
+ # Insert test data
217
+ connector.execute_insert(
218
+ f"""
219
+ INSERT INTO {table_name} (id, name) VALUES
220
+ (1, 'Alice'),
221
+ (2, 'Bob'),
222
+ (3, 'Charlie')
223
+ """
224
+ )
225
+
226
+ try:
227
+ sample_rows = connector.get_sample_rows(database_name=config.database, tables=[table_name], top_n=2)
228
+
229
+ assert len(sample_rows) == 1
230
+ assert sample_rows[0]["table_name"] == table_name
231
+ assert "sample_rows" in sample_rows[0]
232
+ finally:
233
+ connector.execute_ddl(f"DROP TABLE IF EXISTS {table_name}")
234
+
235
+
236
+ # ==================== SQL Execution Tests ====================
237
+
238
+
239
+ def test_execute_select(connector: MySQLConnector):
240
+ """Test executing SELECT query."""
241
+ result = connector.execute({"sql_query": "SELECT 1 as num"}, result_format="list")
242
+ assert result.success
243
+ assert not result.error
244
+ assert result.sql_return == [{"num": 1}]
245
+
246
+
247
+ def test_execute_ddl(connector: MySQLConnector, config: MySQLConfig):
248
+ """Test DDL operations."""
249
+ suffix = uuid.uuid4().hex[:8]
250
+ table_name = f"test_ddl_{suffix}"
251
+
252
+ connector.switch_context(database_name=config.database)
253
+
254
+ try:
255
+ # CREATE
256
+ create_result = connector.execute_ddl(
257
+ f"""
258
+ CREATE TABLE {table_name} (
259
+ id INT PRIMARY KEY,
260
+ name VARCHAR(50)
261
+ )
262
+ """
263
+ )
264
+ assert create_result.success
265
+
266
+ # ALTER
267
+ alter_result = connector.execute_ddl(f"ALTER TABLE {table_name} ADD COLUMN age INT")
268
+ assert alter_result.success
269
+
270
+ finally:
271
+ connector.execute_ddl(f"DROP TABLE IF EXISTS {table_name}")
272
+
273
+
274
+ def test_execute_insert(connector: MySQLConnector, config: MySQLConfig):
275
+ """Test INSERT operation."""
276
+ suffix = uuid.uuid4().hex[:8]
277
+ table_name = f"test_insert_{suffix}"
278
+
279
+ connector.switch_context(database_name=config.database)
280
+ connector.execute_ddl(
281
+ f"""
282
+ CREATE TABLE {table_name} (
283
+ id INT PRIMARY KEY,
284
+ name VARCHAR(50)
285
+ )
286
+ """
287
+ )
288
+
289
+ try:
290
+ insert_result = connector.execute_insert(f"INSERT INTO {table_name} (id, name) VALUES (1, 'Alice'), (2, 'Bob')")
291
+ assert insert_result.success
292
+ assert insert_result.row_count == 2
293
+
294
+ # Verify
295
+ query_result = connector.execute(
296
+ {"sql_query": f"SELECT id, name FROM {table_name} ORDER BY id"}, result_format="list"
297
+ )
298
+ assert query_result.sql_return == [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]
299
+ finally:
300
+ connector.execute_ddl(f"DROP TABLE IF EXISTS {table_name}")
301
+
302
+
303
+ def test_execute_update(connector: MySQLConnector, config: MySQLConfig):
304
+ """Test UPDATE operation."""
305
+ suffix = uuid.uuid4().hex[:8]
306
+ table_name = f"test_update_{suffix}"
307
+
308
+ connector.switch_context(database_name=config.database)
309
+ connector.execute_ddl(
310
+ f"""
311
+ CREATE TABLE {table_name} (
312
+ id INT PRIMARY KEY,
313
+ name VARCHAR(50)
314
+ )
315
+ """
316
+ )
317
+
318
+ try:
319
+ # Insert initial data
320
+ connector.execute_insert(f"INSERT INTO {table_name} (id, name) VALUES (1, 'Alice'), (2, 'Bob')")
321
+
322
+ # Update
323
+ update_result = connector.execute_update(f"UPDATE {table_name} SET name = 'Alice Updated' WHERE id = 1")
324
+ assert update_result.success
325
+ assert update_result.row_count == 1
326
+
327
+ # Verify
328
+ query_result = connector.execute(
329
+ {"sql_query": f"SELECT name FROM {table_name} WHERE id = 1"}, result_format="list"
330
+ )
331
+ assert query_result.sql_return == [{"name": "Alice Updated"}]
332
+ finally:
333
+ connector.execute_ddl(f"DROP TABLE IF EXISTS {table_name}")
334
+
335
+
336
+ def test_execute_delete(connector: MySQLConnector, config: MySQLConfig):
337
+ """Test DELETE operation."""
338
+ suffix = uuid.uuid4().hex[:8]
339
+ table_name = f"test_delete_{suffix}"
340
+
341
+ connector.switch_context(database_name=config.database)
342
+ connector.execute_ddl(
343
+ f"""
344
+ CREATE TABLE {table_name} (
345
+ id INT PRIMARY KEY,
346
+ name VARCHAR(50)
347
+ )
348
+ """
349
+ )
350
+
351
+ try:
352
+ # Insert initial data
353
+ connector.execute_insert(f"INSERT INTO {table_name} (id, name) VALUES (1, 'Alice'), (2, 'Bob')")
354
+
355
+ # Delete
356
+ delete_result = connector.execute_delete(f"DELETE FROM {table_name} WHERE id = 2")
357
+ assert delete_result.success
358
+ assert delete_result.row_count == 1
359
+
360
+ # Verify
361
+ query_result = connector.execute({"sql_query": f"SELECT id FROM {table_name}"}, result_format="list")
362
+ assert query_result.sql_return == [{"id": 1}]
363
+ finally:
364
+ connector.execute_ddl(f"DROP TABLE IF EXISTS {table_name}")
365
+
366
+
367
+ # ==================== Error Handling Tests ====================
368
+
369
+
370
+ def test_exception_on_syntax_error(connector: MySQLConnector):
371
+ """Test exception on SQL syntax error."""
372
+ with pytest.raises(DatusException):
373
+ connector.execute({"sql_query": "INVALID SQL SYNTAX"})
374
+
375
+
376
+ def test_exception_on_nonexistent_table(connector: MySQLConnector):
377
+ """Test exception on non-existent table."""
378
+ with pytest.raises(DatusException):
379
+ connector.execute({"sql_query": f"SELECT * FROM nonexistent_table_{uuid.uuid4().hex}"})
380
+
381
+
382
+ # ==================== Utility Tests ====================
383
+
384
+
385
+ def test_full_name_with_database(connector: MySQLConnector):
386
+ """Test full_name with database."""
387
+ full_name = connector.full_name(database_name="mydb", table_name="mytable")
388
+ assert full_name == "`mydb`.`mytable`"
389
+
390
+
391
+ def test_full_name_without_database(connector: MySQLConnector):
392
+ """Test full_name without database."""
393
+ full_name = connector.full_name(table_name="mytable")
394
+ assert full_name == "`mytable`"
395
+
396
+
397
+ def test_identifier(connector: MySQLConnector):
398
+ """Test identifier generation."""
399
+ identifier = connector.identifier(database_name="mydb", table_name="mytable")
400
+ assert identifier == "mydb.mytable"