sql-query-mcp 0.1.4__tar.gz → 0.2.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.
- {sql_query_mcp-0.1.4/sql_query_mcp.egg-info → sql_query_mcp-0.2.0}/PKG-INFO +16 -8
- {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/README.md +14 -7
- {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/pyproject.toml +2 -1
- {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp/adapters/mysql.py +8 -0
- {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp/adapters/postgres.py +10 -0
- {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp/app.py +24 -0
- sql_query_mcp-0.2.0/sql_query_mcp/importer.py +223 -0
- {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0/sql_query_mcp.egg-info}/PKG-INFO +16 -8
- {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp.egg-info/SOURCES.txt +3 -0
- {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp.egg-info/requires.txt +1 -0
- sql_query_mcp-0.2.0/tests/test_app.py +19 -0
- sql_query_mcp-0.2.0/tests/test_importer.py +330 -0
- {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/tests/test_validator.py +20 -0
- {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/LICENSE +0 -0
- {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/setup.cfg +0 -0
- {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp/__init__.py +0 -0
- {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp/__main__.py +0 -0
- {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp/adapters/__init__.py +0 -0
- {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp/audit.py +0 -0
- {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp/config.py +0 -0
- {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp/errors.py +0 -0
- {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp/executor.py +0 -0
- {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp/introspection.py +0 -0
- {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp/namespace.py +0 -0
- {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp/registry.py +0 -0
- {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp/release_metadata.py +0 -0
- {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp/validator.py +0 -0
- {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp.egg-info/dependency_links.txt +0 -0
- {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp.egg-info/entry_points.txt +0 -0
- {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/sql_query_mcp.egg-info/top_level.txt +0 -0
- {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/tests/test_audit.py +0 -0
- {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/tests/test_config.py +0 -0
- {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/tests/test_executor.py +0 -0
- {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/tests/test_metadata.py +0 -0
- {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/tests/test_namespace.py +0 -0
- {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/tests/test_registry.py +0 -0
- {sql_query_mcp-0.1.4 → sql_query_mcp-0.2.0}/tests/test_release_metadata.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sql-query-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Read-only SQL MCP server for PostgreSQL and MySQL.
|
|
5
5
|
Author: Andy Wang
|
|
6
6
|
License-Expression: MIT
|
|
@@ -21,6 +21,7 @@ Requires-Python: >=3.10
|
|
|
21
21
|
Description-Content-Type: text/markdown
|
|
22
22
|
License-File: LICENSE
|
|
23
23
|
Requires-Dist: mcp>=1.12.4
|
|
24
|
+
Requires-Dist: openpyxl>=3.1
|
|
24
25
|
Requires-Dist: PyMySQL>=1.1
|
|
25
26
|
Requires-Dist: psycopg[binary]>=3.2
|
|
26
27
|
Requires-Dist: psycopg-pool>=3.2
|
|
@@ -35,6 +36,8 @@ Dynamic: license-file
|
|
|
35
36
|
A general-purpose MCP server that lets AI work with multiple databases within
|
|
36
37
|
clear boundaries.
|
|
37
38
|
|
|
39
|
+
[](https://glama.ai/mcp/servers/andyWang1688/sql-query-mcp)
|
|
40
|
+
|
|
38
41
|
## Current database support
|
|
39
42
|
|
|
40
43
|
| Database | Status | Current availability |
|
|
@@ -56,9 +59,10 @@ without exposing raw connection strings or flattening engine-specific concepts.
|
|
|
56
59
|
|
|
57
60
|
## What AI can do with it
|
|
58
61
|
|
|
59
|
-
The current tool set focuses on database discovery
|
|
60
|
-
|
|
61
|
-
it generates or
|
|
62
|
+
The current tool set focuses on database discovery, controlled query workflows,
|
|
63
|
+
and one narrow local file import path. You can use it to help an AI assistant
|
|
64
|
+
understand structure before it generates SQL or imports a prepared CSV/XLSX file
|
|
65
|
+
into an existing table.
|
|
62
66
|
|
|
63
67
|
MySQL supports `explain_query`, but not `explain_query(..., analyze=True)` in
|
|
64
68
|
the current implementation.
|
|
@@ -73,16 +77,18 @@ the current implementation.
|
|
|
73
77
|
| `run_select(connection_id, sql, limit?)` | Yes | Yes | Run read-only queries |
|
|
74
78
|
| `explain_query(connection_id, sql, analyze?)` | Yes | Yes | Inspect query plans |
|
|
75
79
|
| `get_table_sample(connection_id, table_name, schema?, database?, limit?)` | Yes | Yes | Fetch small table samples |
|
|
80
|
+
| `import_table_file(connection_id, table_name, file_path, schema?, database?, sheet_name?)` | Yes | Yes | Import local CSV/XLSX files |
|
|
76
81
|
|
|
77
82
|
These tools are useful for tasks such as listing namespaces, inspecting table
|
|
78
|
-
definitions, reviewing indexes, sampling records,
|
|
79
|
-
|
|
80
|
-
`docs/api-reference.md` (Chinese).
|
|
83
|
+
definitions, reviewing indexes, sampling records, analyzing read-only queries
|
|
84
|
+
with `EXPLAIN`, and importing prepared local files. For full request and
|
|
85
|
+
response details, see `docs/api-reference.md` (Chinese).
|
|
81
86
|
|
|
82
87
|
## How boundaries are constrained
|
|
83
88
|
|
|
84
89
|
The product boundary is intentionally narrow today. Only PostgreSQL and MySQL
|
|
85
|
-
are available today, and the
|
|
90
|
+
are available today. Query tools remain read-only, and the only write path is a
|
|
91
|
+
controlled local CSV/XLSX import into existing tables.
|
|
86
92
|
|
|
87
93
|
The service keeps those boundaries explicit in a few ways.
|
|
88
94
|
|
|
@@ -96,6 +102,8 @@ The service keeps those boundaries explicit in a few ways.
|
|
|
96
102
|
database.
|
|
97
103
|
- The server accepts only `SELECT` and `WITH ... SELECT`, rejects comments and
|
|
98
104
|
multi-statement input, and records audit logs for each call.
|
|
105
|
+
- `import_table_file` doesn't accept raw SQL. It inserts only file columns whose
|
|
106
|
+
headers exactly match existing table columns.
|
|
99
107
|
|
|
100
108
|
For MySQL, `explain_query(..., analyze=True)` is not available in the current
|
|
101
109
|
implementation.
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
A general-purpose MCP server that lets AI work with multiple databases within
|
|
6
6
|
clear boundaries.
|
|
7
7
|
|
|
8
|
+
[](https://glama.ai/mcp/servers/andyWang1688/sql-query-mcp)
|
|
9
|
+
|
|
8
10
|
## Current database support
|
|
9
11
|
|
|
10
12
|
| Database | Status | Current availability |
|
|
@@ -26,9 +28,10 @@ without exposing raw connection strings or flattening engine-specific concepts.
|
|
|
26
28
|
|
|
27
29
|
## What AI can do with it
|
|
28
30
|
|
|
29
|
-
The current tool set focuses on database discovery
|
|
30
|
-
|
|
31
|
-
it generates or
|
|
31
|
+
The current tool set focuses on database discovery, controlled query workflows,
|
|
32
|
+
and one narrow local file import path. You can use it to help an AI assistant
|
|
33
|
+
understand structure before it generates SQL or imports a prepared CSV/XLSX file
|
|
34
|
+
into an existing table.
|
|
32
35
|
|
|
33
36
|
MySQL supports `explain_query`, but not `explain_query(..., analyze=True)` in
|
|
34
37
|
the current implementation.
|
|
@@ -43,16 +46,18 @@ the current implementation.
|
|
|
43
46
|
| `run_select(connection_id, sql, limit?)` | Yes | Yes | Run read-only queries |
|
|
44
47
|
| `explain_query(connection_id, sql, analyze?)` | Yes | Yes | Inspect query plans |
|
|
45
48
|
| `get_table_sample(connection_id, table_name, schema?, database?, limit?)` | Yes | Yes | Fetch small table samples |
|
|
49
|
+
| `import_table_file(connection_id, table_name, file_path, schema?, database?, sheet_name?)` | Yes | Yes | Import local CSV/XLSX files |
|
|
46
50
|
|
|
47
51
|
These tools are useful for tasks such as listing namespaces, inspecting table
|
|
48
|
-
definitions, reviewing indexes, sampling records,
|
|
49
|
-
|
|
50
|
-
`docs/api-reference.md` (Chinese).
|
|
52
|
+
definitions, reviewing indexes, sampling records, analyzing read-only queries
|
|
53
|
+
with `EXPLAIN`, and importing prepared local files. For full request and
|
|
54
|
+
response details, see `docs/api-reference.md` (Chinese).
|
|
51
55
|
|
|
52
56
|
## How boundaries are constrained
|
|
53
57
|
|
|
54
58
|
The product boundary is intentionally narrow today. Only PostgreSQL and MySQL
|
|
55
|
-
are available today, and the
|
|
59
|
+
are available today. Query tools remain read-only, and the only write path is a
|
|
60
|
+
controlled local CSV/XLSX import into existing tables.
|
|
56
61
|
|
|
57
62
|
The service keeps those boundaries explicit in a few ways.
|
|
58
63
|
|
|
@@ -66,6 +71,8 @@ The service keeps those boundaries explicit in a few ways.
|
|
|
66
71
|
database.
|
|
67
72
|
- The server accepts only `SELECT` and `WITH ... SELECT`, rejects comments and
|
|
68
73
|
multi-statement input, and records audit logs for each call.
|
|
74
|
+
- `import_table_file` doesn't accept raw SQL. It inserts only file columns whose
|
|
75
|
+
headers exactly match existing table columns.
|
|
69
76
|
|
|
70
77
|
For MySQL, `explain_query(..., analyze=True)` is not available in the current
|
|
71
78
|
implementation.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "sql-query-mcp"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Read-only SQL MCP server for PostgreSQL and MySQL."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -24,6 +24,7 @@ classifiers = [
|
|
|
24
24
|
]
|
|
25
25
|
dependencies = [
|
|
26
26
|
"mcp>=1.12.4",
|
|
27
|
+
"openpyxl>=3.1",
|
|
27
28
|
"PyMySQL>=1.1",
|
|
28
29
|
"psycopg[binary]>=3.2",
|
|
29
30
|
"psycopg-pool>=3.2",
|
|
@@ -115,6 +115,14 @@ class MySQLAdapter:
|
|
|
115
115
|
f"{self._quote_identifier(table_name)} LIMIT {int(sentinel_limit)}"
|
|
116
116
|
)
|
|
117
117
|
|
|
118
|
+
def build_insert_query(self, database: str, table_name: str, columns: List[str]) -> str:
|
|
119
|
+
quoted_columns = ", ".join(self._quote_identifier(column) for column in columns)
|
|
120
|
+
placeholders = ", ".join(["%s"] * len(columns))
|
|
121
|
+
return (
|
|
122
|
+
f"INSERT INTO {self._quote_identifier(database)}."
|
|
123
|
+
f"{self._quote_identifier(table_name)} ({quoted_columns}) VALUES ({placeholders})"
|
|
124
|
+
)
|
|
125
|
+
|
|
118
126
|
def build_explain_query(self, sql_text: str, analyze: bool = False) -> str:
|
|
119
127
|
if analyze:
|
|
120
128
|
raise SecurityError("MySQL 首版不支持 analyze=True。")
|
|
@@ -155,6 +155,16 @@ class PostgresAdapter:
|
|
|
155
155
|
sql.Literal(sentinel_limit),
|
|
156
156
|
)
|
|
157
157
|
|
|
158
|
+
def build_insert_query(self, schema: str, table_name: str, columns: List[str]):
|
|
159
|
+
if sql is None:
|
|
160
|
+
raise ConfigurationError("缺少 psycopg 依赖,请先安装项目依赖。")
|
|
161
|
+
return sql.SQL("INSERT INTO {}.{} ({}) VALUES ({})").format(
|
|
162
|
+
sql.Identifier(schema),
|
|
163
|
+
sql.Identifier(table_name),
|
|
164
|
+
sql.SQL(", ").join(sql.Identifier(column) for column in columns),
|
|
165
|
+
sql.SQL(", ").join(sql.Placeholder() for _ in columns),
|
|
166
|
+
)
|
|
167
|
+
|
|
158
168
|
def build_explain_query(self, sql_text: str, analyze: bool = False) -> str:
|
|
159
169
|
return f"EXPLAIN (FORMAT JSON, ANALYZE {'TRUE' if analyze else 'FALSE'}) {sql_text}"
|
|
160
170
|
|
|
@@ -10,6 +10,7 @@ from .audit import AuditLogger
|
|
|
10
10
|
from .config import load_config
|
|
11
11
|
from .errors import SqlQueryMCPError
|
|
12
12
|
from .executor import QueryExecutor
|
|
13
|
+
from .importer import TableFileImporter
|
|
13
14
|
from .introspection import MetadataService
|
|
14
15
|
from .registry import ConnectionRegistry
|
|
15
16
|
|
|
@@ -20,6 +21,7 @@ def create_app() -> FastMCP:
|
|
|
20
21
|
audit_logger = AuditLogger(app_config.settings.audit_log_path)
|
|
21
22
|
metadata = MetadataService(registry, app_config.settings, audit_logger)
|
|
22
23
|
executor = QueryExecutor(registry, app_config.settings, audit_logger)
|
|
24
|
+
importer = TableFileImporter(registry, app_config.settings, audit_logger)
|
|
23
25
|
|
|
24
26
|
mcp = FastMCP("sql-query-mcp", json_response=True)
|
|
25
27
|
|
|
@@ -86,6 +88,28 @@ def create_app() -> FastMCP:
|
|
|
86
88
|
|
|
87
89
|
return _run_tool(lambda: executor.get_table_sample(connection_id, table_name, schema, database, limit))
|
|
88
90
|
|
|
91
|
+
@mcp.tool()
|
|
92
|
+
def import_table_file(
|
|
93
|
+
connection_id: str,
|
|
94
|
+
table_name: str,
|
|
95
|
+
file_path: str,
|
|
96
|
+
schema: Optional[str] = None,
|
|
97
|
+
database: Optional[str] = None,
|
|
98
|
+
sheet_name: Optional[str] = None,
|
|
99
|
+
) -> dict:
|
|
100
|
+
"""Import a local CSV or XLSX file into an existing table."""
|
|
101
|
+
|
|
102
|
+
return _run_tool(
|
|
103
|
+
lambda: importer.import_table_file(
|
|
104
|
+
connection_id,
|
|
105
|
+
table_name,
|
|
106
|
+
file_path,
|
|
107
|
+
schema,
|
|
108
|
+
database,
|
|
109
|
+
sheet_name,
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
|
|
89
113
|
return mcp
|
|
90
114
|
|
|
91
115
|
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Controlled local file imports into existing tables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from openpyxl import load_workbook
|
|
12
|
+
except ImportError: # pragma: no cover - runtime dependency
|
|
13
|
+
load_workbook = None
|
|
14
|
+
|
|
15
|
+
from .audit import AuditLogger
|
|
16
|
+
from .config import ServerSettings
|
|
17
|
+
from .errors import QueryExecutionError, sanitize_error_message
|
|
18
|
+
from .namespace import NamespaceSelection, resolve_namespace
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TableFileImporter:
|
|
22
|
+
"""Import CSV and XLSX files through a constrained table-only path."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
registry: Any,
|
|
27
|
+
settings: ServerSettings,
|
|
28
|
+
audit_logger: AuditLogger,
|
|
29
|
+
):
|
|
30
|
+
self._registry = registry
|
|
31
|
+
self._settings = settings
|
|
32
|
+
self._audit = audit_logger
|
|
33
|
+
|
|
34
|
+
def import_table_file(
|
|
35
|
+
self,
|
|
36
|
+
connection_id: str,
|
|
37
|
+
table_name: str,
|
|
38
|
+
file_path: str,
|
|
39
|
+
schema: Optional[str] = None,
|
|
40
|
+
database: Optional[str] = None,
|
|
41
|
+
sheet_name: Optional[str] = None,
|
|
42
|
+
) -> Dict[str, object]:
|
|
43
|
+
started = time.perf_counter()
|
|
44
|
+
config = None
|
|
45
|
+
namespace = None
|
|
46
|
+
file_extension = Path(file_path).suffix.lower()
|
|
47
|
+
selected_sheet_name = None
|
|
48
|
+
inserted_row_count = 0
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
config = self._registry.get_connection_config(connection_id)
|
|
52
|
+
namespace = resolve_namespace(config, schema=schema, database=database)
|
|
53
|
+
headers, rows, selected_sheet_name = _read_file(Path(file_path), sheet_name)
|
|
54
|
+
if not rows:
|
|
55
|
+
raise QueryExecutionError("文件没有可导入的数据行。")
|
|
56
|
+
|
|
57
|
+
with self._registry.connection_from_config(config) as (conn, adapter):
|
|
58
|
+
_apply_statement_timeout(adapter, conn, self._settings.statement_timeout_ms)
|
|
59
|
+
description = adapter.describe_table(conn, namespace.value, table_name)
|
|
60
|
+
if not description:
|
|
61
|
+
raise QueryExecutionError(
|
|
62
|
+
f"未找到表 {namespace.value}.{table_name},或当前用户没有访问权限"
|
|
63
|
+
)
|
|
64
|
+
table_columns = [item["column_name"] for item in description["columns"]]
|
|
65
|
+
_validate_headers(headers, table_columns)
|
|
66
|
+
query = adapter.build_insert_query(namespace.value, table_name, headers)
|
|
67
|
+
_execute_insert(conn, config.engine, query, rows)
|
|
68
|
+
|
|
69
|
+
inserted_row_count = len(rows)
|
|
70
|
+
duration_ms = _elapsed_ms(started)
|
|
71
|
+
self._audit.log(
|
|
72
|
+
tool="import_table_file",
|
|
73
|
+
connection_id=connection_id,
|
|
74
|
+
success=True,
|
|
75
|
+
duration_ms=duration_ms,
|
|
76
|
+
row_count=inserted_row_count,
|
|
77
|
+
extra=_build_audit_extra(
|
|
78
|
+
config,
|
|
79
|
+
namespace,
|
|
80
|
+
table_name,
|
|
81
|
+
file_extension,
|
|
82
|
+
selected_sheet_name,
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
return {
|
|
86
|
+
"connection_id": connection_id,
|
|
87
|
+
"engine": config.engine,
|
|
88
|
+
namespace.field_name: namespace.value,
|
|
89
|
+
"table_name": table_name,
|
|
90
|
+
"inserted_row_count": inserted_row_count,
|
|
91
|
+
"duration_ms": duration_ms,
|
|
92
|
+
"file_extension": file_extension,
|
|
93
|
+
"sheet_name": selected_sheet_name,
|
|
94
|
+
}
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
duration_ms = _elapsed_ms(started)
|
|
97
|
+
sanitized = sanitize_error_message(str(exc))
|
|
98
|
+
self._audit.log(
|
|
99
|
+
tool="import_table_file",
|
|
100
|
+
connection_id=connection_id,
|
|
101
|
+
success=False,
|
|
102
|
+
duration_ms=duration_ms,
|
|
103
|
+
row_count=inserted_row_count,
|
|
104
|
+
error=sanitized,
|
|
105
|
+
extra=_build_audit_extra(
|
|
106
|
+
config,
|
|
107
|
+
namespace,
|
|
108
|
+
table_name,
|
|
109
|
+
file_extension,
|
|
110
|
+
selected_sheet_name,
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
raise QueryExecutionError(sanitized) from exc
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _read_file(path: Path, sheet_name: Optional[str]) -> Tuple[List[str], List[Tuple[object, ...]], Optional[str]]:
|
|
117
|
+
extension = path.suffix.lower()
|
|
118
|
+
if extension == ".csv":
|
|
119
|
+
if sheet_name:
|
|
120
|
+
raise QueryExecutionError("CSV 文件不支持 sheet_name 参数。")
|
|
121
|
+
return _read_csv(path)
|
|
122
|
+
if extension == ".xlsx":
|
|
123
|
+
return _read_xlsx(path, sheet_name)
|
|
124
|
+
raise QueryExecutionError("仅支持 .csv 和 .xlsx 文件导入。")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _read_csv(path: Path) -> Tuple[List[str], List[Tuple[object, ...]], Optional[str]]:
|
|
128
|
+
with path.open("r", encoding="utf-8-sig", newline="") as handle:
|
|
129
|
+
reader = csv.reader(handle)
|
|
130
|
+
try:
|
|
131
|
+
headers = next(reader)
|
|
132
|
+
except StopIteration as exc:
|
|
133
|
+
raise QueryExecutionError("文件表头不能为空。") from exc
|
|
134
|
+
rows = [_normalize_row(row, len(headers)) for row in reader]
|
|
135
|
+
return headers, rows, None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _read_xlsx(path: Path, sheet_name: Optional[str]) -> Tuple[List[str], List[Tuple[object, ...]], Optional[str]]:
|
|
139
|
+
if load_workbook is None:
|
|
140
|
+
raise QueryExecutionError("缺少 openpyxl 依赖,请先安装项目依赖。")
|
|
141
|
+
workbook = load_workbook(path, read_only=True, data_only=True)
|
|
142
|
+
try:
|
|
143
|
+
if sheet_name:
|
|
144
|
+
if sheet_name not in workbook.sheetnames:
|
|
145
|
+
raise QueryExecutionError(f"XLSX 文件中不存在 sheet: {sheet_name}")
|
|
146
|
+
worksheet = workbook[sheet_name]
|
|
147
|
+
else:
|
|
148
|
+
worksheet = workbook.worksheets[0]
|
|
149
|
+
rows_iter = worksheet.iter_rows(values_only=True)
|
|
150
|
+
try:
|
|
151
|
+
header_row = next(rows_iter)
|
|
152
|
+
except StopIteration as exc:
|
|
153
|
+
raise QueryExecutionError("文件表头不能为空。") from exc
|
|
154
|
+
headers = ["" if value is None else str(value) for value in header_row]
|
|
155
|
+
rows = [_normalize_row(list(row), len(headers)) for row in rows_iter]
|
|
156
|
+
return headers, rows, worksheet.title
|
|
157
|
+
finally:
|
|
158
|
+
workbook.close()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _normalize_row(row: Sequence[object], expected_length: int) -> Tuple[object, ...]:
|
|
162
|
+
if len(row) != expected_length:
|
|
163
|
+
raise QueryExecutionError("数据行字段数量必须和表头数量一致。")
|
|
164
|
+
return tuple(None if value == "" else value for value in row)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _validate_headers(headers: Sequence[str], table_columns: Sequence[str]) -> None:
|
|
168
|
+
if not headers:
|
|
169
|
+
raise QueryExecutionError("文件表头不能为空。")
|
|
170
|
+
empty_headers = [index + 1 for index, header in enumerate(headers) if not header]
|
|
171
|
+
if empty_headers:
|
|
172
|
+
raise QueryExecutionError(f"文件表头存在空字段,位置: {empty_headers}")
|
|
173
|
+
duplicates = sorted({header for header in headers if headers.count(header) > 1})
|
|
174
|
+
if duplicates:
|
|
175
|
+
raise QueryExecutionError(f"文件表头存在重复字段: {duplicates}")
|
|
176
|
+
unknown = sorted(set(headers) - set(table_columns))
|
|
177
|
+
if unknown:
|
|
178
|
+
raise QueryExecutionError(f"文件表头包含目标表不存在的字段: {unknown}")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _execute_insert(conn: Any, engine: str, query: object, rows: List[Tuple[object, ...]]) -> None:
|
|
182
|
+
if engine == "postgres" and hasattr(conn, "transaction"):
|
|
183
|
+
with conn.transaction():
|
|
184
|
+
with conn.cursor() as cur:
|
|
185
|
+
cur.executemany(query, rows)
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
conn.begin()
|
|
189
|
+
try:
|
|
190
|
+
with conn.cursor() as cur:
|
|
191
|
+
cur.executemany(query, rows)
|
|
192
|
+
conn.commit()
|
|
193
|
+
except Exception:
|
|
194
|
+
conn.rollback()
|
|
195
|
+
raise
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _elapsed_ms(started: float) -> int:
|
|
199
|
+
return int((time.perf_counter() - started) * 1000)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _apply_statement_timeout(adapter: Any, conn: Any, timeout_ms: Optional[int]) -> None:
|
|
203
|
+
if timeout_ms is not None:
|
|
204
|
+
getattr(adapter, "set_statement_timeout")(conn, timeout_ms)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _build_audit_extra(
|
|
208
|
+
config: Any,
|
|
209
|
+
namespace: Optional[NamespaceSelection],
|
|
210
|
+
table_name: str,
|
|
211
|
+
file_extension: str,
|
|
212
|
+
sheet_name: Optional[str],
|
|
213
|
+
) -> Dict[str, object]:
|
|
214
|
+
extra: Dict[str, object] = {
|
|
215
|
+
"table_name": table_name,
|
|
216
|
+
"file_extension": file_extension,
|
|
217
|
+
"sheet_name": sheet_name,
|
|
218
|
+
}
|
|
219
|
+
if config is not None:
|
|
220
|
+
extra["engine"] = config.engine
|
|
221
|
+
if namespace is not None:
|
|
222
|
+
extra[namespace.field_name] = namespace.value
|
|
223
|
+
return extra
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sql-query-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Read-only SQL MCP server for PostgreSQL and MySQL.
|
|
5
5
|
Author: Andy Wang
|
|
6
6
|
License-Expression: MIT
|
|
@@ -21,6 +21,7 @@ Requires-Python: >=3.10
|
|
|
21
21
|
Description-Content-Type: text/markdown
|
|
22
22
|
License-File: LICENSE
|
|
23
23
|
Requires-Dist: mcp>=1.12.4
|
|
24
|
+
Requires-Dist: openpyxl>=3.1
|
|
24
25
|
Requires-Dist: PyMySQL>=1.1
|
|
25
26
|
Requires-Dist: psycopg[binary]>=3.2
|
|
26
27
|
Requires-Dist: psycopg-pool>=3.2
|
|
@@ -35,6 +36,8 @@ Dynamic: license-file
|
|
|
35
36
|
A general-purpose MCP server that lets AI work with multiple databases within
|
|
36
37
|
clear boundaries.
|
|
37
38
|
|
|
39
|
+
[](https://glama.ai/mcp/servers/andyWang1688/sql-query-mcp)
|
|
40
|
+
|
|
38
41
|
## Current database support
|
|
39
42
|
|
|
40
43
|
| Database | Status | Current availability |
|
|
@@ -56,9 +59,10 @@ without exposing raw connection strings or flattening engine-specific concepts.
|
|
|
56
59
|
|
|
57
60
|
## What AI can do with it
|
|
58
61
|
|
|
59
|
-
The current tool set focuses on database discovery
|
|
60
|
-
|
|
61
|
-
it generates or
|
|
62
|
+
The current tool set focuses on database discovery, controlled query workflows,
|
|
63
|
+
and one narrow local file import path. You can use it to help an AI assistant
|
|
64
|
+
understand structure before it generates SQL or imports a prepared CSV/XLSX file
|
|
65
|
+
into an existing table.
|
|
62
66
|
|
|
63
67
|
MySQL supports `explain_query`, but not `explain_query(..., analyze=True)` in
|
|
64
68
|
the current implementation.
|
|
@@ -73,16 +77,18 @@ the current implementation.
|
|
|
73
77
|
| `run_select(connection_id, sql, limit?)` | Yes | Yes | Run read-only queries |
|
|
74
78
|
| `explain_query(connection_id, sql, analyze?)` | Yes | Yes | Inspect query plans |
|
|
75
79
|
| `get_table_sample(connection_id, table_name, schema?, database?, limit?)` | Yes | Yes | Fetch small table samples |
|
|
80
|
+
| `import_table_file(connection_id, table_name, file_path, schema?, database?, sheet_name?)` | Yes | Yes | Import local CSV/XLSX files |
|
|
76
81
|
|
|
77
82
|
These tools are useful for tasks such as listing namespaces, inspecting table
|
|
78
|
-
definitions, reviewing indexes, sampling records,
|
|
79
|
-
|
|
80
|
-
`docs/api-reference.md` (Chinese).
|
|
83
|
+
definitions, reviewing indexes, sampling records, analyzing read-only queries
|
|
84
|
+
with `EXPLAIN`, and importing prepared local files. For full request and
|
|
85
|
+
response details, see `docs/api-reference.md` (Chinese).
|
|
81
86
|
|
|
82
87
|
## How boundaries are constrained
|
|
83
88
|
|
|
84
89
|
The product boundary is intentionally narrow today. Only PostgreSQL and MySQL
|
|
85
|
-
are available today, and the
|
|
90
|
+
are available today. Query tools remain read-only, and the only write path is a
|
|
91
|
+
controlled local CSV/XLSX import into existing tables.
|
|
86
92
|
|
|
87
93
|
The service keeps those boundaries explicit in a few ways.
|
|
88
94
|
|
|
@@ -96,6 +102,8 @@ The service keeps those boundaries explicit in a few ways.
|
|
|
96
102
|
database.
|
|
97
103
|
- The server accepts only `SELECT` and `WITH ... SELECT`, rejects comments and
|
|
98
104
|
multi-statement input, and records audit logs for each call.
|
|
105
|
+
- `import_table_file` doesn't accept raw SQL. It inserts only file columns whose
|
|
106
|
+
headers exactly match existing table columns.
|
|
99
107
|
|
|
100
108
|
For MySQL, `explain_query(..., analyze=True)` is not available in the current
|
|
101
109
|
implementation.
|
|
@@ -8,6 +8,7 @@ sql_query_mcp/audit.py
|
|
|
8
8
|
sql_query_mcp/config.py
|
|
9
9
|
sql_query_mcp/errors.py
|
|
10
10
|
sql_query_mcp/executor.py
|
|
11
|
+
sql_query_mcp/importer.py
|
|
11
12
|
sql_query_mcp/introspection.py
|
|
12
13
|
sql_query_mcp/namespace.py
|
|
13
14
|
sql_query_mcp/registry.py
|
|
@@ -22,9 +23,11 @@ sql_query_mcp.egg-info/top_level.txt
|
|
|
22
23
|
sql_query_mcp/adapters/__init__.py
|
|
23
24
|
sql_query_mcp/adapters/mysql.py
|
|
24
25
|
sql_query_mcp/adapters/postgres.py
|
|
26
|
+
tests/test_app.py
|
|
25
27
|
tests/test_audit.py
|
|
26
28
|
tests/test_config.py
|
|
27
29
|
tests/test_executor.py
|
|
30
|
+
tests/test_importer.py
|
|
28
31
|
tests/test_metadata.py
|
|
29
32
|
tests/test_namespace.py
|
|
30
33
|
tests/test_registry.py
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import unittest
|
|
5
|
+
|
|
6
|
+
from sql_query_mcp.app import create_app
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AppTestCase(unittest.TestCase):
|
|
10
|
+
def test_create_app_registers_import_table_file_tool(self) -> None:
|
|
11
|
+
app = create_app()
|
|
12
|
+
|
|
13
|
+
tools = asyncio.run(app.list_tools())
|
|
14
|
+
|
|
15
|
+
self.assertIn("import_table_file", {tool.name for tool in tools})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
if __name__ == "__main__":
|
|
19
|
+
unittest.main()
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import json
|
|
5
|
+
import tempfile
|
|
6
|
+
import unittest
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from openpyxl import Workbook
|
|
11
|
+
|
|
12
|
+
from sql_query_mcp.audit import AuditLogger
|
|
13
|
+
from sql_query_mcp.config import ConnectionConfig, ServerSettings
|
|
14
|
+
from sql_query_mcp.errors import QueryExecutionError
|
|
15
|
+
from sql_query_mcp.importer import TableFileImporter
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _CursorStub:
|
|
19
|
+
def __init__(self, error: Exception | None = None) -> None:
|
|
20
|
+
self._error = error
|
|
21
|
+
self.executed_many = []
|
|
22
|
+
|
|
23
|
+
def __enter__(self):
|
|
24
|
+
return self
|
|
25
|
+
|
|
26
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
def executemany(self, query, rows) -> None:
|
|
30
|
+
if self._error is not None:
|
|
31
|
+
raise self._error
|
|
32
|
+
self.executed_many.append((query, rows))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _ConnectionStub:
|
|
36
|
+
def __init__(self, error: Exception | None = None) -> None:
|
|
37
|
+
self.cursor_stub = _CursorStub(error)
|
|
38
|
+
self.begin_calls = 0
|
|
39
|
+
self.commit_calls = 0
|
|
40
|
+
self.rollback_calls = 0
|
|
41
|
+
|
|
42
|
+
def cursor(self) -> _CursorStub:
|
|
43
|
+
return self.cursor_stub
|
|
44
|
+
|
|
45
|
+
def begin(self) -> None:
|
|
46
|
+
self.begin_calls += 1
|
|
47
|
+
|
|
48
|
+
def commit(self) -> None:
|
|
49
|
+
self.commit_calls += 1
|
|
50
|
+
|
|
51
|
+
def rollback(self) -> None:
|
|
52
|
+
self.rollback_calls += 1
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class _AdapterStub:
|
|
56
|
+
def __init__(self) -> None:
|
|
57
|
+
self.set_statement_timeout_calls = []
|
|
58
|
+
|
|
59
|
+
def set_statement_timeout(self, conn: object, timeout_ms: int) -> None:
|
|
60
|
+
self.set_statement_timeout_calls.append(timeout_ms)
|
|
61
|
+
|
|
62
|
+
def describe_table(self, conn: object, namespace: str, table_name: str):
|
|
63
|
+
return {
|
|
64
|
+
"columns": [
|
|
65
|
+
{"column_name": "id"},
|
|
66
|
+
{"column_name": "name"},
|
|
67
|
+
{"column_name": "status"},
|
|
68
|
+
],
|
|
69
|
+
"indexes": [],
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
def build_insert_query(self, namespace: str, table_name: str, columns):
|
|
73
|
+
return f"insert {namespace}.{table_name} ({','.join(columns)})"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _RegistryStub:
|
|
77
|
+
def __init__(self, config: ConnectionConfig, adapter: object, conn: object) -> None:
|
|
78
|
+
self._config = config
|
|
79
|
+
self._adapter = adapter
|
|
80
|
+
self._conn = conn
|
|
81
|
+
|
|
82
|
+
def get_connection_config(self, connection_id: str) -> ConnectionConfig:
|
|
83
|
+
if connection_id != self._config.connection_id:
|
|
84
|
+
raise AssertionError(connection_id)
|
|
85
|
+
return self._config
|
|
86
|
+
|
|
87
|
+
@contextmanager
|
|
88
|
+
def connection_from_config(self, config: ConnectionConfig):
|
|
89
|
+
if config != self._config:
|
|
90
|
+
raise AssertionError(config)
|
|
91
|
+
yield self._conn, self._adapter
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TableFileImporterTestCase(unittest.TestCase):
|
|
95
|
+
def test_import_csv_inserts_header_subset(self) -> None:
|
|
96
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
97
|
+
csv_path = _write_csv(
|
|
98
|
+
Path(temp_dir) / "users.csv",
|
|
99
|
+
[["name", "status"], ["Alice", "active"], ["Bob", "disabled"]],
|
|
100
|
+
)
|
|
101
|
+
conn = _ConnectionStub()
|
|
102
|
+
log_path = Path(temp_dir) / "audit.jsonl"
|
|
103
|
+
importer = _build_importer(log_path, conn)
|
|
104
|
+
|
|
105
|
+
result = importer.import_table_file(
|
|
106
|
+
"crm_mysql_prod_main_rw",
|
|
107
|
+
"users",
|
|
108
|
+
str(csv_path),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
records = _read_audit_records(log_path)
|
|
112
|
+
|
|
113
|
+
self.assertEqual(2, result["inserted_row_count"])
|
|
114
|
+
self.assertEqual(".csv", result["file_extension"])
|
|
115
|
+
self.assertEqual("crm", result["database"])
|
|
116
|
+
self.assertEqual(1, conn.begin_calls)
|
|
117
|
+
self.assertEqual(1, conn.commit_calls)
|
|
118
|
+
self.assertEqual(0, conn.rollback_calls)
|
|
119
|
+
self.assertEqual(
|
|
120
|
+
[("insert crm.users (name,status)", [("Alice", "active"), ("Bob", "disabled")])],
|
|
121
|
+
conn.cursor_stub.executed_many,
|
|
122
|
+
)
|
|
123
|
+
self.assertEqual("import_table_file", records[0]["tool"])
|
|
124
|
+
self.assertEqual(2, records[0]["row_count"])
|
|
125
|
+
self.assertEqual(".csv", records[0]["extra"]["file_extension"])
|
|
126
|
+
|
|
127
|
+
def test_import_csv_strips_utf8_bom_from_first_header(self) -> None:
|
|
128
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
129
|
+
csv_path = _write_csv(
|
|
130
|
+
Path(temp_dir) / "users.csv",
|
|
131
|
+
[["id", "name"], ["1", "Alice"]],
|
|
132
|
+
encoding="utf-8-sig",
|
|
133
|
+
)
|
|
134
|
+
conn = _ConnectionStub()
|
|
135
|
+
importer = _build_importer(Path(temp_dir) / "audit.jsonl", conn)
|
|
136
|
+
|
|
137
|
+
result = importer.import_table_file(
|
|
138
|
+
"crm_mysql_prod_main_rw",
|
|
139
|
+
"users",
|
|
140
|
+
str(csv_path),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
self.assertEqual(1, result["inserted_row_count"])
|
|
144
|
+
self.assertEqual(
|
|
145
|
+
[("insert crm.users (id,name)", [("1", "Alice")])],
|
|
146
|
+
conn.cursor_stub.executed_many,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def test_import_csv_rejects_unknown_header_before_insert(self) -> None:
|
|
150
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
151
|
+
csv_path = _write_csv(Path(temp_dir) / "users.csv", [["missing"], ["x"]])
|
|
152
|
+
conn = _ConnectionStub()
|
|
153
|
+
importer = _build_importer(Path(temp_dir) / "audit.jsonl", conn)
|
|
154
|
+
|
|
155
|
+
with self.assertRaises(QueryExecutionError):
|
|
156
|
+
importer.import_table_file("crm_mysql_prod_main_rw", "users", str(csv_path))
|
|
157
|
+
|
|
158
|
+
self.assertEqual([], conn.cursor_stub.executed_many)
|
|
159
|
+
self.assertEqual(0, conn.begin_calls)
|
|
160
|
+
|
|
161
|
+
def test_import_csv_rejects_duplicate_header_before_insert(self) -> None:
|
|
162
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
163
|
+
csv_path = _write_csv(Path(temp_dir) / "users.csv", [["name", "name"], ["x", "y"]])
|
|
164
|
+
conn = _ConnectionStub()
|
|
165
|
+
importer = _build_importer(Path(temp_dir) / "audit.jsonl", conn)
|
|
166
|
+
|
|
167
|
+
with self.assertRaises(QueryExecutionError):
|
|
168
|
+
importer.import_table_file("crm_mysql_prod_main_rw", "users", str(csv_path))
|
|
169
|
+
|
|
170
|
+
self.assertEqual([], conn.cursor_stub.executed_many)
|
|
171
|
+
self.assertEqual(0, conn.begin_calls)
|
|
172
|
+
|
|
173
|
+
def test_import_csv_rejects_empty_header_before_insert(self) -> None:
|
|
174
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
175
|
+
csv_path = _write_csv(Path(temp_dir) / "users.csv", [["name", ""], ["x", "y"]])
|
|
176
|
+
conn = _ConnectionStub()
|
|
177
|
+
importer = _build_importer(Path(temp_dir) / "audit.jsonl", conn)
|
|
178
|
+
|
|
179
|
+
with self.assertRaises(QueryExecutionError):
|
|
180
|
+
importer.import_table_file("crm_mysql_prod_main_rw", "users", str(csv_path))
|
|
181
|
+
|
|
182
|
+
self.assertEqual([], conn.cursor_stub.executed_many)
|
|
183
|
+
self.assertEqual(0, conn.begin_calls)
|
|
184
|
+
|
|
185
|
+
def test_import_file_rejects_unsupported_extension_before_insert(self) -> None:
|
|
186
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
187
|
+
txt_path = Path(temp_dir) / "users.txt"
|
|
188
|
+
txt_path.write_text("name\nAlice\n", encoding="utf-8")
|
|
189
|
+
conn = _ConnectionStub()
|
|
190
|
+
importer = _build_importer(Path(temp_dir) / "audit.jsonl", conn)
|
|
191
|
+
|
|
192
|
+
with self.assertRaises(QueryExecutionError):
|
|
193
|
+
importer.import_table_file("crm_mysql_prod_main_rw", "users", str(txt_path))
|
|
194
|
+
|
|
195
|
+
self.assertEqual([], conn.cursor_stub.executed_many)
|
|
196
|
+
self.assertEqual(0, conn.begin_calls)
|
|
197
|
+
|
|
198
|
+
def test_import_csv_rejects_file_without_data_rows_before_insert(self) -> None:
|
|
199
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
200
|
+
csv_path = _write_csv(Path(temp_dir) / "users.csv", [["name"]])
|
|
201
|
+
conn = _ConnectionStub()
|
|
202
|
+
importer = _build_importer(Path(temp_dir) / "audit.jsonl", conn)
|
|
203
|
+
|
|
204
|
+
with self.assertRaises(QueryExecutionError):
|
|
205
|
+
importer.import_table_file("crm_mysql_prod_main_rw", "users", str(csv_path))
|
|
206
|
+
|
|
207
|
+
self.assertEqual([], conn.cursor_stub.executed_many)
|
|
208
|
+
self.assertEqual(0, conn.begin_calls)
|
|
209
|
+
|
|
210
|
+
def test_import_rolls_back_when_database_insert_fails(self) -> None:
|
|
211
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
212
|
+
csv_path = _write_csv(Path(temp_dir) / "users.csv", [["name"], ["Alice"]])
|
|
213
|
+
conn = _ConnectionStub(RuntimeError("dsn://user:secret@db failed"))
|
|
214
|
+
log_path = Path(temp_dir) / "audit.jsonl"
|
|
215
|
+
importer = _build_importer(log_path, conn)
|
|
216
|
+
|
|
217
|
+
with self.assertRaises(QueryExecutionError) as caught:
|
|
218
|
+
importer.import_table_file("crm_mysql_prod_main_rw", "users", str(csv_path))
|
|
219
|
+
|
|
220
|
+
records = _read_audit_records(log_path)
|
|
221
|
+
|
|
222
|
+
self.assertIn("dsn://user:***@db failed", str(caught.exception))
|
|
223
|
+
self.assertEqual(1, conn.begin_calls)
|
|
224
|
+
self.assertEqual(0, conn.commit_calls)
|
|
225
|
+
self.assertEqual(1, conn.rollback_calls)
|
|
226
|
+
self.assertFalse(records[0]["success"])
|
|
227
|
+
|
|
228
|
+
def test_import_xlsx_uses_first_sheet_by_default(self) -> None:
|
|
229
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
230
|
+
xlsx_path = _write_xlsx(Path(temp_dir) / "users.xlsx")
|
|
231
|
+
conn = _ConnectionStub()
|
|
232
|
+
importer = _build_importer(Path(temp_dir) / "audit.jsonl", conn)
|
|
233
|
+
|
|
234
|
+
result = importer.import_table_file(
|
|
235
|
+
"crm_mysql_prod_main_rw",
|
|
236
|
+
"users",
|
|
237
|
+
str(xlsx_path),
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
self.assertEqual("First", result["sheet_name"])
|
|
241
|
+
self.assertEqual(1, result["inserted_row_count"])
|
|
242
|
+
self.assertEqual(
|
|
243
|
+
[("insert crm.users (name)", [("Alice",)])],
|
|
244
|
+
conn.cursor_stub.executed_many,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def test_import_xlsx_uses_named_sheet_when_provided(self) -> None:
|
|
248
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
249
|
+
xlsx_path = _write_xlsx(Path(temp_dir) / "users.xlsx")
|
|
250
|
+
conn = _ConnectionStub()
|
|
251
|
+
importer = _build_importer(Path(temp_dir) / "audit.jsonl", conn)
|
|
252
|
+
|
|
253
|
+
result = importer.import_table_file(
|
|
254
|
+
"crm_mysql_prod_main_rw",
|
|
255
|
+
"users",
|
|
256
|
+
str(xlsx_path),
|
|
257
|
+
sheet_name="Data",
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
self.assertEqual("Data", result["sheet_name"])
|
|
261
|
+
self.assertEqual(1, result["inserted_row_count"])
|
|
262
|
+
self.assertEqual(
|
|
263
|
+
[("insert crm.users (status)", [("active",)])],
|
|
264
|
+
conn.cursor_stub.executed_many,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def test_import_xlsx_rejects_missing_sheet_before_insert(self) -> None:
|
|
268
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
269
|
+
xlsx_path = _write_xlsx(Path(temp_dir) / "users.xlsx")
|
|
270
|
+
conn = _ConnectionStub()
|
|
271
|
+
importer = _build_importer(Path(temp_dir) / "audit.jsonl", conn)
|
|
272
|
+
|
|
273
|
+
with self.assertRaises(QueryExecutionError):
|
|
274
|
+
importer.import_table_file(
|
|
275
|
+
"crm_mysql_prod_main_rw",
|
|
276
|
+
"users",
|
|
277
|
+
str(xlsx_path),
|
|
278
|
+
sheet_name="Missing",
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
self.assertEqual([], conn.cursor_stub.executed_many)
|
|
282
|
+
self.assertEqual(0, conn.begin_calls)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _build_importer(log_path: Path, conn: _ConnectionStub) -> TableFileImporter:
|
|
286
|
+
config = ConnectionConfig(
|
|
287
|
+
connection_id="crm_mysql_prod_main_rw",
|
|
288
|
+
engine="mysql",
|
|
289
|
+
label="CRM MySQL",
|
|
290
|
+
env="prod",
|
|
291
|
+
tenant="main",
|
|
292
|
+
role="rw",
|
|
293
|
+
dsn_env="MYSQL_CONN",
|
|
294
|
+
enabled=True,
|
|
295
|
+
default_database="crm",
|
|
296
|
+
)
|
|
297
|
+
return TableFileImporter(
|
|
298
|
+
registry=_RegistryStub(config, _AdapterStub(), conn),
|
|
299
|
+
settings=ServerSettings(audit_log_path=log_path),
|
|
300
|
+
audit_logger=AuditLogger(log_path),
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _write_csv(path: Path, rows: list[list[str]], encoding: str = "utf-8") -> Path:
|
|
305
|
+
with path.open("w", encoding=encoding, newline="") as handle:
|
|
306
|
+
writer = csv.writer(handle)
|
|
307
|
+
writer.writerows(rows)
|
|
308
|
+
return path
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _write_xlsx(path: Path) -> Path:
|
|
312
|
+
workbook = Workbook()
|
|
313
|
+
first = workbook.active
|
|
314
|
+
assert first is not None
|
|
315
|
+
first.title = "First"
|
|
316
|
+
first.append(["name"])
|
|
317
|
+
first.append(["Alice"])
|
|
318
|
+
second = workbook.create_sheet("Data")
|
|
319
|
+
second.append(["status"])
|
|
320
|
+
second.append(["active"])
|
|
321
|
+
workbook.save(path)
|
|
322
|
+
return path
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _read_audit_records(path: Path) -> list[dict]:
|
|
326
|
+
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()]
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
if __name__ == "__main__":
|
|
330
|
+
unittest.main()
|
|
@@ -73,6 +73,26 @@ class ValidatorTestCase(unittest.TestCase):
|
|
|
73
73
|
)
|
|
74
74
|
self.assertEqual({"query_block": {"select_id": 1}}, plan)
|
|
75
75
|
|
|
76
|
+
def test_postgres_build_insert_query_quotes_identifiers(self) -> None:
|
|
77
|
+
query = PostgresAdapter().build_insert_query(
|
|
78
|
+
"public", "orders", ["order", "status"]
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
self.assertEqual(
|
|
82
|
+
'INSERT INTO "public"."orders" ("order", "status") VALUES (%s, %s)',
|
|
83
|
+
query.as_string(None),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def test_mysql_build_insert_query_quotes_identifiers(self) -> None:
|
|
87
|
+
query = MySQLAdapter().build_insert_query(
|
|
88
|
+
"crm", "orders", ["order", "status"]
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
self.assertEqual(
|
|
92
|
+
"INSERT INTO `crm`.`orders` (`order`, `status`) VALUES (%s, %s)",
|
|
93
|
+
query,
|
|
94
|
+
)
|
|
95
|
+
|
|
76
96
|
def test_mysql_indexes_are_normalized(self) -> None:
|
|
77
97
|
indexes = MySQLAdapter()._normalize_indexes(
|
|
78
98
|
[
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|