feat: add MySQL database support with SQL translation
- Add MySQLConnection class with automatic SQL Server to MySQL translation - Add connection factory to support both SQL Server and MySQL - Update config schema to support MySQL configuration (host, port, db_type) - Update default config to use MySQL (localhost:3306) - Translate table names: [schema].[table] -> schema_table - Translate placeholders: ? -> %s - Translate MERGE statements to INSERT ... ON DUPLICATE KEY UPDATE Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -195,5 +195,9 @@ def get_connection() -> DatabaseConnection:
|
||||
|
||||
Returns:
|
||||
DatabaseConnection: 数据库连接对象
|
||||
|
||||
Note:
|
||||
此函数现在从 connection_factory 导入,以支持 MySQL 和 SQL Server
|
||||
"""
|
||||
return DatabaseConnection()
|
||||
from db.connection_factory import get_connection as factory_get_connection
|
||||
return factory_get_connection()
|
||||
|
||||
40
db/connection_factory.py
Normal file
40
db/connection_factory.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
数据库连接工厂
|
||||
|
||||
根据配置返回 MySQL 或 SQL Server 连接实例。
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from db.connection import DatabaseConnection
|
||||
from db.mysql_connection import MySQLConnection
|
||||
from config.defaults import DEFAULT_APP_CONFIG
|
||||
|
||||
|
||||
def get_connection(db_type: Optional[str] = None):
|
||||
"""
|
||||
根据配置返回 MySQL 或 SQL Server 连接
|
||||
|
||||
Args:
|
||||
db_type: 数据库类型,"mysql" 或 "sqlserver"。
|
||||
如果为 None,则从 DEFAULT_APP_CONFIG 读取配置
|
||||
|
||||
Returns:
|
||||
DatabaseConnection 或 MySQLConnection 实例
|
||||
|
||||
Example:
|
||||
>>> # 使用默认配置
|
||||
>>> conn = get_connection()
|
||||
|
||||
>>> # 强制使用 MySQL
|
||||
>>> conn = get_connection("mysql")
|
||||
|
||||
>>> # 强制使用 SQL Server
|
||||
>>> conn = get_connection("sqlserver")
|
||||
"""
|
||||
if db_type is None:
|
||||
db_type = DEFAULT_APP_CONFIG.database.db_type
|
||||
|
||||
if db_type == "mysql":
|
||||
return MySQLConnection()
|
||||
|
||||
return DatabaseConnection()
|
||||
@@ -98,7 +98,7 @@ class DiscreteMaterialPlanDAO:
|
||||
for i in range(0, len(plan_numbers), batch_size):
|
||||
batch = plan_numbers[i:i + batch_size]
|
||||
placeholders = ','.join(['?' for _ in batch])
|
||||
sql = f"DELETE FROM DiscreteMaterialPlanData WHERE PlanNumber IN ({placeholders})"
|
||||
sql = f"DELETE FROM [dbo].[DiscreteMaterialPlanData] WHERE PlanNumber IN ({placeholders})"
|
||||
deleted = db.execute_update(sql, tuple(batch))
|
||||
total_deleted += deleted
|
||||
|
||||
@@ -120,7 +120,7 @@ class DiscreteMaterialPlanDAO:
|
||||
Total number of records inserted
|
||||
"""
|
||||
sql = """
|
||||
INSERT INTO DiscreteMaterialPlanData (
|
||||
INSERT INTO [dbo].[DiscreteMaterialPlanData] (
|
||||
Factory, MaterialStatus, PlanNumber, SourceNumber, MaterialType,
|
||||
ProductCode, ProductName, ProductUnit, ProductPlanQuantity,
|
||||
UseDepartment, Remark, Creator, CreateDate, Approver, ApproveDate,
|
||||
@@ -217,7 +217,7 @@ class DiscreteMaterialPlanDAO:
|
||||
List of dictionaries representing records
|
||||
"""
|
||||
with get_connection() as db:
|
||||
sql = "SELECT * FROM DiscreteMaterialPlanData WHERE PlanNumber = ?"
|
||||
sql = "SELECT * FROM [dbo].[DiscreteMaterialPlanData] WHERE PlanNumber = ?"
|
||||
return db.execute_query(sql, (plan_number,))
|
||||
|
||||
def query_by_plan_numbers(self, plan_numbers: List[str]) -> List[Dict]:
|
||||
@@ -233,7 +233,7 @@ class DiscreteMaterialPlanDAO:
|
||||
if not plan_numbers:
|
||||
return []
|
||||
placeholders = ','.join(['?' for _ in plan_numbers])
|
||||
sql = f"SELECT * FROM DiscreteMaterialPlanData WHERE PlanNumber IN ({placeholders})"
|
||||
sql = f"SELECT * FROM [dbo].[DiscreteMaterialPlanData] WHERE PlanNumber IN ({placeholders})"
|
||||
with get_connection() as db:
|
||||
return db.execute_query(sql, tuple(plan_numbers))
|
||||
|
||||
@@ -248,7 +248,7 @@ class DiscreteMaterialPlanDAO:
|
||||
List of dictionaries representing records
|
||||
"""
|
||||
with get_connection() as db:
|
||||
sql = "SELECT * FROM DiscreteMaterialPlanData WHERE SourceNumber = ?"
|
||||
sql = "SELECT * FROM [dbo].[DiscreteMaterialPlanData] WHERE SourceNumber = ?"
|
||||
return db.execute_query(sql, (order_id,))
|
||||
|
||||
def count_by_plan_number(self, plan_number: str) -> int:
|
||||
@@ -262,7 +262,7 @@ class DiscreteMaterialPlanDAO:
|
||||
Number of records
|
||||
"""
|
||||
with get_connection() as db:
|
||||
sql = "SELECT COUNT(*) as count FROM DiscreteMaterialPlanData WHERE PlanNumber = ?"
|
||||
sql = "SELECT COUNT(*) as count FROM [dbo].[DiscreteMaterialPlanData] WHERE PlanNumber = ?"
|
||||
result = db.execute_query(sql, (plan_number,))
|
||||
return result[0]['count'] if result else 0
|
||||
|
||||
@@ -274,7 +274,7 @@ class DiscreteMaterialPlanDAO:
|
||||
Total number of records
|
||||
"""
|
||||
with get_connection() as db:
|
||||
sql = "SELECT COUNT(*) as count FROM DiscreteMaterialPlanData"
|
||||
sql = "SELECT COUNT(*) as count FROM [dbo].[DiscreteMaterialPlanData]"
|
||||
result = db.execute_query(sql)
|
||||
return result[0]['count'] if result else 0
|
||||
|
||||
@@ -307,7 +307,7 @@ class DiscreteMaterialPlanDAO:
|
||||
COUNT(DISTINCT SourceNumber) as unique_orders,
|
||||
MIN(CreateDate) as earliest_record,
|
||||
MAX(CreateDate) as latest_record
|
||||
FROM DiscreteMaterialPlanData
|
||||
FROM [dbo].[DiscreteMaterialPlanData]
|
||||
"""
|
||||
result = db.execute_query(sql)
|
||||
return result[0] if result else {}
|
||||
@@ -322,7 +322,7 @@ class DiscreteMaterialPlanDAO:
|
||||
List of dictionaries representing all records
|
||||
"""
|
||||
with get_connection() as db:
|
||||
sql = "SELECT * FROM DiscreteMaterialPlanData"
|
||||
sql = "SELECT * FROM [dbo].[DiscreteMaterialPlanData]"
|
||||
return db.execute_query(sql)
|
||||
|
||||
def query_by_source_numbers(self, source_numbers: List[str]) -> List[Dict]:
|
||||
@@ -345,7 +345,7 @@ class DiscreteMaterialPlanDAO:
|
||||
for i in range(0, len(source_numbers), batch_size):
|
||||
batch = source_numbers[i:i + batch_size]
|
||||
placeholders = ','.join(['?' for _ in batch])
|
||||
sql = f"SELECT * FROM DiscreteMaterialPlanData WHERE SourceNumber IN ({placeholders})"
|
||||
sql = f"SELECT * FROM [dbo].[DiscreteMaterialPlanData] WHERE SourceNumber IN ({placeholders})"
|
||||
with get_connection() as db:
|
||||
results = db.execute_query(sql, tuple(batch))
|
||||
all_results.extend(results)
|
||||
@@ -364,7 +364,7 @@ class DiscreteMaterialPlanDAO:
|
||||
"""
|
||||
if source_numbers is None or not source_numbers:
|
||||
# No filter - get all unique material names
|
||||
sql = "SELECT DISTINCT MaterialName FROM DiscreteMaterialPlanData WHERE MaterialName IS NOT NULL"
|
||||
sql = "SELECT DISTINCT MaterialName FROM [dbo].[DiscreteMaterialPlanData] WHERE MaterialName IS NOT NULL"
|
||||
with get_connection() as db:
|
||||
results = db.execute_query(sql)
|
||||
return [r['MaterialName'] for r in results if r.get('MaterialName')]
|
||||
@@ -378,7 +378,7 @@ class DiscreteMaterialPlanDAO:
|
||||
placeholders = ','.join(['?' for _ in batch])
|
||||
sql = f"""
|
||||
SELECT DISTINCT MaterialName
|
||||
FROM DiscreteMaterialPlanData
|
||||
FROM [dbo].[DiscreteMaterialPlanData]
|
||||
WHERE SourceNumber IN ({placeholders})
|
||||
AND MaterialName IS NOT NULL
|
||||
"""
|
||||
|
||||
324
db/mysql_connection.py
Normal file
324
db/mysql_connection.py
Normal file
@@ -0,0 +1,324 @@
|
||||
"""
|
||||
MySQL 数据库连接组件
|
||||
|
||||
提供 MySQL 数据库连接和查询接口,实现与 DatabaseConnection 相同的接口。
|
||||
包含自动 SQL 转换功能,将 SQL Server SQL 转换为 MySQL 兼容格式。
|
||||
"""
|
||||
|
||||
import mysql.connector
|
||||
from typing import List, Dict, Any, Optional
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
|
||||
# 添加项目根目录到 sys.path
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
from config.defaults import DEFAULT_APP_CONFIG
|
||||
|
||||
|
||||
class MySQLConnection:
|
||||
"""MySQL 数据库连接类,实现与 DatabaseConnection 相同的接口"""
|
||||
|
||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||
"""
|
||||
初始化 MySQL 数据库连接
|
||||
|
||||
Args:
|
||||
config: 数据库配置字典,默认从 DEFAULT_APP_CONFIG 读取
|
||||
"""
|
||||
if config is None:
|
||||
# 从默认配置获取 MySQL 配置
|
||||
config = {
|
||||
"host": DEFAULT_APP_CONFIG.database.host,
|
||||
"port": DEFAULT_APP_CONFIG.database.port,
|
||||
"database": DEFAULT_APP_CONFIG.database.database,
|
||||
"user": DEFAULT_APP_CONFIG.database.username,
|
||||
"password": DEFAULT_APP_CONFIG.database.password,
|
||||
}
|
||||
|
||||
self.config = config
|
||||
self.connection = None
|
||||
|
||||
def connect(self) -> mysql.connector.MySQLConnection:
|
||||
"""
|
||||
建立 MySQL 数据库连接
|
||||
|
||||
Returns:
|
||||
mysql.connector.MySQLConnection: 数据库连接对象
|
||||
"""
|
||||
if self.connection is not None:
|
||||
return self.connection
|
||||
|
||||
try:
|
||||
self.connection = mysql.connector.connect(
|
||||
host=self.config['host'],
|
||||
port=self.config['port'],
|
||||
database=self.config['database'],
|
||||
user=self.config['user'],
|
||||
password=self.config['password'],
|
||||
charset='utf8mb4',
|
||||
autocommit=False
|
||||
)
|
||||
print(
|
||||
f"成功连接到 MySQL 数据库: {self.config['host']}:{self.config['port']}/{self.config['database']}"
|
||||
)
|
||||
return self.connection
|
||||
except mysql.connector.Error as e:
|
||||
print(f"MySQL 数据库连接失败: {e}")
|
||||
raise
|
||||
|
||||
def disconnect(self):
|
||||
"""关闭数据库连接"""
|
||||
if self.connection:
|
||||
self.connection.close()
|
||||
self.connection = None
|
||||
print("MySQL 数据库连接已关闭")
|
||||
|
||||
def _translate_table_name(self, sql: str) -> str:
|
||||
"""
|
||||
将 SQL Server 表名格式转换为 MySQL 格式
|
||||
|
||||
转换规则:
|
||||
- [schema].[table] -> schema_table
|
||||
- 表名中的空格替换为下划线
|
||||
|
||||
Args:
|
||||
sql: SQL 语句
|
||||
|
||||
Returns:
|
||||
转换后的 SQL 语句
|
||||
"""
|
||||
# 匹配 [schema].[table] 格式
|
||||
pattern = r'\[([a-zA-Z_][a-zA-Z0-9_]*)\]\.\[([^\]]+)\]'
|
||||
|
||||
def replace_table_name(match):
|
||||
schema = match.group(1)
|
||||
table = match.group(2)
|
||||
# 将表名中的空格替换为下划线
|
||||
table = table.replace(' ', '_')
|
||||
return f"{schema}_{table}"
|
||||
|
||||
result = re.sub(pattern, replace_table_name, sql)
|
||||
return result
|
||||
|
||||
def _translate_placeholder(self, sql: str) -> str:
|
||||
"""
|
||||
将 SQL Server 占位符转换为 MySQL 格式
|
||||
|
||||
转换规则:
|
||||
- ? -> %s
|
||||
|
||||
Args:
|
||||
sql: SQL 语句
|
||||
|
||||
Returns:
|
||||
转换后的 SQL 语句
|
||||
"""
|
||||
return sql.replace('?', '%s')
|
||||
|
||||
def _translate_merge(self, sql: str) -> str:
|
||||
"""
|
||||
将 T-SQL MERGE 语句转换为 MySQL INSERT ... ON DUPLICATE KEY UPDATE
|
||||
|
||||
示例输入:
|
||||
MERGE [dbo].[MaterialsToBeDeleted] AS target
|
||||
USING (SELECT ? AS MaterialCode, ? AS ManagerName) AS source
|
||||
ON (target.MaterialCode = source.MaterialCode)
|
||||
WHEN MATCHED THEN UPDATE SET ManagerName = source.ManagerName
|
||||
WHEN NOT MATCHED THEN INSERT (MaterialCode, ManagerName) VALUES (...);
|
||||
|
||||
示例输出:
|
||||
INSERT INTO dbo_MaterialsToBeDeleted (MaterialCode, ManagerName)
|
||||
VALUES (%s, %s)
|
||||
ON DUPLICATE KEY UPDATE ManagerName = VALUES(ManagerName);
|
||||
|
||||
Args:
|
||||
sql: SQL 语句
|
||||
|
||||
Returns:
|
||||
转换后的 SQL 语句
|
||||
"""
|
||||
# 检测是否为 MERGE 语句
|
||||
if not re.match(r'\s*MERGE', sql, re.IGNORECASE):
|
||||
return sql
|
||||
|
||||
# 解析 MERGE 语句的各个部分
|
||||
# 这是一个简化的实现,假设 MERGE 语句遵循标准格式
|
||||
|
||||
# 先转换表名,再解析
|
||||
sql_with_translated_table = self._translate_table_name(sql)
|
||||
|
||||
# 提取目标表
|
||||
target_match = re.search(r'MERGE\s+(\S+)\s+AS\s+target', sql_with_translated_table, re.IGNORECASE)
|
||||
if not target_match:
|
||||
return sql
|
||||
|
||||
target_table = target_match.group(1)
|
||||
|
||||
# 提取 INSERT 的列
|
||||
insert_match = re.search(r'INSERT\s*\(([^)]+)\)\s*VALUES\s*\(([^)]+)\)', sql, re.IGNORECASE)
|
||||
if not insert_match:
|
||||
return sql
|
||||
|
||||
columns = insert_match.group(1).strip()
|
||||
values_part = insert_match.group(2).strip()
|
||||
|
||||
# 提取 UPDATE 部分
|
||||
update_match = re.search(r'UPDATE\s+SET\s+([^\s]+)\s*=\s*source\.([^\s]+)', sql, re.IGNORECASE)
|
||||
if not update_match:
|
||||
return sql
|
||||
|
||||
update_column = update_match.group(1)
|
||||
|
||||
# 从 USING 子句中统计占位符数量
|
||||
# 匹配: USING (SELECT ? AS MaterialCode, ? AS ManagerName) AS source
|
||||
using_match = re.search(r'USING\s*\((.+)\)\s+AS\s+source', sql, re.IGNORECASE | re.DOTALL)
|
||||
if not using_match:
|
||||
return sql
|
||||
|
||||
using_clause = using_match.group(1)
|
||||
# 从 SELECT 部分提取
|
||||
select_match = re.search(r'SELECT\s+(.+)', using_clause, re.IGNORECASE)
|
||||
if not select_match:
|
||||
return sql
|
||||
|
||||
using_select = select_match.group(1)
|
||||
# 计算占位符(?)的数量
|
||||
placeholder_count = using_select.count('?')
|
||||
|
||||
# 生成相应数量的 %s 占位符
|
||||
mysql_placeholders = ', '.join(['%s'] * placeholder_count)
|
||||
|
||||
# 构建 MySQL INSERT ... ON DUPLICATE KEY UPDATE 语句
|
||||
mysql_sql = f"""
|
||||
INSERT INTO {target_table} ({columns})
|
||||
VALUES ({mysql_placeholders})
|
||||
ON DUPLICATE KEY UPDATE {update_column} = VALUES({update_column})
|
||||
""".strip()
|
||||
|
||||
return mysql_sql
|
||||
|
||||
def _translate_sql(self, sql: str) -> str:
|
||||
"""
|
||||
将 SQL Server SQL 转换为 MySQL 兼容格式
|
||||
|
||||
转换顺序:
|
||||
1. 表名转换 ([schema].[table] -> schema_table)
|
||||
2. 占位符转换 (? -> %s)
|
||||
3. MERGE 语句转换
|
||||
|
||||
Args:
|
||||
sql: SQL 语句
|
||||
|
||||
Returns:
|
||||
转换后的 SQL 语句
|
||||
"""
|
||||
result = sql
|
||||
|
||||
# 1. 表名转换
|
||||
result = self._translate_table_name(result)
|
||||
|
||||
# 2. 占位符转换
|
||||
result = self._translate_placeholder(result)
|
||||
|
||||
# 3. MERGE 语句转换
|
||||
if re.match(r'\s*MERGE', sql, re.IGNORECASE):
|
||||
result = self._translate_merge(sql)
|
||||
# MERGE 转换已经处理了表名和占位符,所以需要重新处理
|
||||
result = self._translate_table_name(result)
|
||||
result = self._translate_placeholder(result)
|
||||
|
||||
return result
|
||||
|
||||
def execute_query(
|
||||
self, sql: str, params: Optional[tuple] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
执行查询语句并返回结果
|
||||
|
||||
Args:
|
||||
sql: SQL 查询语句
|
||||
params: 查询参数(可选)
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 查询结果列表,每个元素为一行数据的字典
|
||||
"""
|
||||
if not self.connection:
|
||||
self.connect()
|
||||
|
||||
# 转换 SQL
|
||||
translated_sql = self._translate_sql(sql)
|
||||
|
||||
cursor = self.connection.cursor(dictionary=True)
|
||||
|
||||
try:
|
||||
if params:
|
||||
cursor.execute(translated_sql, params)
|
||||
else:
|
||||
cursor.execute(translated_sql)
|
||||
|
||||
# 将结果转换为字典列表
|
||||
results = cursor.fetchall()
|
||||
|
||||
return results
|
||||
|
||||
except mysql.connector.Error as e:
|
||||
print(f"查询执行失败: {e}")
|
||||
print(f"原始 SQL: {sql}")
|
||||
print(f"转换后 SQL: {translated_sql}")
|
||||
if params:
|
||||
print(f"参数: {params}")
|
||||
raise
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
def execute_update(self, sql: str, params: Optional[tuple] = None) -> int:
|
||||
"""
|
||||
执行更新/插入/删除语句
|
||||
|
||||
Args:
|
||||
sql: SQL 语句
|
||||
params: 参数(可选)
|
||||
|
||||
Returns:
|
||||
int: 受影响的行数
|
||||
"""
|
||||
if not self.connection:
|
||||
self.connect()
|
||||
|
||||
# 转换 SQL
|
||||
translated_sql = self._translate_sql(sql)
|
||||
|
||||
cursor = self.connection.cursor()
|
||||
|
||||
try:
|
||||
if params:
|
||||
cursor.execute(translated_sql, params)
|
||||
else:
|
||||
cursor.execute(translated_sql)
|
||||
|
||||
self.connection.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
except mysql.connector.Error as e:
|
||||
self.connection.rollback()
|
||||
print(f"执行失败,已回滚: {e}")
|
||||
print(f"原始 SQL: {sql}")
|
||||
print(f"转换后 SQL: {translated_sql}")
|
||||
if params:
|
||||
print(f"参数: {params}")
|
||||
raise
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
def __enter__(self):
|
||||
"""支持 with 语句的上下文管理器入口"""
|
||||
self.connect()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""支持 with 语句的上下文管理器出口"""
|
||||
self.disconnect()
|
||||
Reference in New Issue
Block a user