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.
- datus_mysql-0.1.0/.gitignore +137 -0
- datus_mysql-0.1.0/PKG-INFO +102 -0
- datus_mysql-0.1.0/README.md +81 -0
- datus_mysql-0.1.0/datus_mysql/__init__.py +16 -0
- datus_mysql-0.1.0/datus_mysql/config.py +22 -0
- datus_mysql-0.1.0/datus_mysql/connector.py +398 -0
- datus_mysql-0.1.0/pyproject.toml +42 -0
- datus_mysql-0.1.0/tests/__init__.py +0 -0
- datus_mysql-0.1.0/tests/test_connector.py +400 -0
|
@@ -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"
|