sqlite3 迁移 到mysql8 亲测有效

5 阅读6分钟

sqlite3 迁移 到mysql8 亲测有效,有问题评论区交流

import sqlite3
import mysql.connector
from mysql.connector import Error
import sys
import logging
from typing import List, Tuple, Optional


class DatabaseMigration:
    """
    SQLite3 到 MySQL8 数据库迁移工具
    """

    def __init__(self, sqlite_path: str, mysql_config: dict):
        self.sqlite_path = sqlite_path
        self.mysql_config = mysql_config
        self.sqlite_conn = None
        self.mysql_conn = None

        # 设置日志
        logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
        self.logger = logging.getLogger(__name__)

    def get_mysql_column_type(self, sqlite_type: str) -> str:
        """
        根据SQLite类型映射到MySQL类型
        """
        sqlite_type = sqlite_type.upper().strip()

        if 'INTEGER' in sqlite_type:
            return 'INT'
        elif 'TEXT' in sqlite_type or 'CHAR' in sqlite_type or 'CLOB' in sqlite_type:
            return 'LONGTEXT'
        elif 'BLOB' in sqlite_type:
            return 'LONGBLOB'
        elif 'REAL' in sqlite_type or 'DOUBLE' in sqlite_type or 'FLOAT' in sqlite_type:
            return 'DOUBLE'
        elif 'BOOLEAN' in sqlite_type or 'BOOL' in sqlite_type:
            return 'TINYINT(1)'
        elif 'DATE' in sqlite_type:
            return 'DATE'
        elif 'DATETIME' in sqlite_type or 'TIMESTAMP' in sqlite_type:
            return 'DATETIME'
        elif 'TIME' in sqlite_type:
            return 'TIME'
        elif 'NUMERIC' in sqlite_type or 'DECIMAL' in sqlite_type:
            # 尝试提取精度信息
            import re
            match = re.search(r'NUMERIC\s*((\d+),\s*(\d+))', sqlite_type, re.IGNORECASE)
            if match:
                precision = match.group(1)
                scale = match.group(2)
                return f'DECIMAL({precision}, {scale})'
            else:
                return 'DECIMAL(10, 2)'
        else:
            # 检查是否有长度定义
            import re
            match = re.search(r'(\w+)\s*((\d+))', sqlite_type)
            if match:
                base_type = match.group(1).upper()
                length = match.group(2)
                if 'VARCHAR' in base_type or 'CHAR' in base_type:
                    return f'VARCHAR({length})'

            return 'VARCHAR(255)'  # 默认类型

    def format_default_value(self, default_value, sqlite_type: str) -> str:
        """
        格式化默认值,根据数据类型决定是否加引号
        """
        if default_value is None:
            return ''

        sqlite_type_upper = sqlite_type.upper()

        # 检查是否为TEXT或BLOB类型,这些类型在MySQL中不能有默认值
        if any(t in sqlite_type_upper for t in ['TEXT', 'CLOB', 'BLOB']):
            self.logger.warning(f"TEXT/BLOB类型列不能在MySQL中设置默认值,将忽略默认值 '{default_value}'")
            return ""

        # 对于数值类型不需要引号
        if any(t in sqlite_type_upper for t in ['INTEGER', 'REAL', 'DOUBLE', 'FLOAT', 'NUMERIC', 'DECIMAL']):
            try:
                # 尝试转换为数值
                str_val = str(default_value)
                if '.' in str_val:
                    float(str_val)
                else:
                    int(str_val)
                return f"DEFAULT {str_val}"
            except ValueError:
                # 如果不是有效数字,则作为字符串处理
                pass

        # 对于布尔类型特殊处理
        if 'BOOLEAN' in sqlite_type_upper or 'BOOL' in sqlite_type_upper:
            if str(default_value).lower() in ('true', '1', 'yes', 'on'):
                return "DEFAULT 1"
            elif str(default_value).lower() in ('false', '0', 'no', 'off'):
                return "DEFAULT 0"
            else:
                # 如果不是标准布尔值,尝试作为字符串处理
                # 但首先检查是否为有效的布尔值字符串
                try:
                    # 尝试转换为布尔值
                    bool_val = bool(default_value)
                    return "DEFAULT 1" if bool_val else "DEFAULT 0"
                except:
                    # 如果不能转换为布尔值,返回空字符串(不设置默认值)
                    self.logger.warning(f"无法将 '{default_value}' 识别为有效的布尔值,将不设置默认值")
                    return ""

        # 对于日期时间类型
        if 'DATE' in sqlite_type_upper or 'TIME' in sqlite_type_upper or 'TIMESTAMP' in sqlite_type_upper:
            # 检查是否是CURRENT_TIMESTAMP这样的特殊值
            if str(default_value).upper() in ('CURRENT_TIMESTAMP', 'NOW()', 'DATETIME("NOW")', 'DATE("NOW")'):
                return "DEFAULT CURRENT_TIMESTAMP"
            else:
                # 验证日期格式
                import re
                date_pattern = r'^\d{4}-\d{2}-\d{2}$'
                datetime_pattern = r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$'

                str_val = str(default_value)
                if re.match(date_pattern, str_val) or re.match(datetime_pattern, str_val):
                    # 是有效的日期格式
                    return f"DEFAULT '{str_val}'"
                else:
                    # 不是有效的日期格式,跳过默认值
                    self.logger.warning(f"日期类型列的默认值 '{default_value}' 格式无效,将忽略默认值")
                    return ""

        # 对于NULL值
        if str(default_value).upper() == 'NULL':
            return "DEFAULT NULL"

        # 其他情况都加上引号
        # 转义字符串中的单引号
        escaped_value = str(default_value).replace("'", "''")
        return f"DEFAULT '{escaped_value}'"

    def create_mysql_table_from_sqlite(self, mysql_cur, table_name: str, sqlite_columns_info: List[Tuple]):
        """
        根据SQLite表结构创建MySQL表
        """
        columns_def = []
        primary_keys = []

        # 分析列信息
        for col_info in sqlite_columns_info:
            col_name = col_info[1]
            col_type = col_info[2] if col_info[2] else 'TEXT'
            not_null = 'NOT NULL' if col_info[3] == 1 else ''
            default_val = self.format_default_value(col_info[4], col_type) if col_info[4] is not None else ''
            is_primary = col_info[5] == 1  # 主键标识

            if is_primary:
                primary_keys.append(col_name)

            # 检查是否为主键的TEXT类型列,如果是则转换为VARCHAR
            mysql_type = self.get_mysql_column_type(col_type)
            if is_primary and col_type.upper() in ['TEXT', 'CLOB']:
                # 对于主键的TEXT类型,使用VARCHAR(255)替代
                mysql_type = 'VARCHAR(255)'
                self.logger.info(f"将主键列 '{col_name}' 的类型从 TEXT 转换为 VARCHAR(255)")

            column_def = f"`{col_name}` {mysql_type} {not_null} {default_val}".strip()
            columns_def.append(column_def)

        # 检查表是否已存在
        check_table_sql = """
        SELECT COUNT(*) 
        FROM information_schema.tables 
        WHERE table_schema = DATABASE() AND table_name = %s
        """
        mysql_cur.execute(check_table_sql, (table_name,))
        if mysql_cur.fetchone()[0] > 0:
            self.logger.info(f"表 {table_name} 已存在,跳过创建")
            return

        # 构建主键定义
        if primary_keys:
            primary_key_def = f", PRIMARY KEY(`{'`, `'.join(primary_keys)}`)"
        else:
            primary_key_def = ""

        # 创建表
        create_table_sql = f"CREATE TABLE `{table_name}` ({', '.join(columns_def)}{primary_key_def})"

        try:
            mysql_cur.execute(create_table_sql)
            self.logger.info(f"成功创建表: {table_name}")
        except Error as e:
            self.logger.error(f"创建表 {table_name} 失败: {e}")
            # 尝试不指定主键的方式创建
            create_table_sql = f"CREATE TABLE `{table_name}` ({', '.join(columns_def)})"
            try:
                mysql_cur.execute(create_table_sql)
                self.logger.info(f"使用备用方案创建表: {table_name}")
            except Error as e2:
                self.logger.error(f"备用方案创建表也失败: {e2}")
                raise e2

    def migrate_single_table(self, sqlite_cur, mysql_cur, table_name: str):
        """
        迁移单个表的数据
        """
        self.logger.info(f"\n正在处理表: {table_name}")

        # 获取SQLite表的结构信息
        sqlite_cur.execute(f"PRAGMA table_info({table_name})")
        columns_info = sqlite_cur.fetchall()

        if not columns_info:
            self.logger.warning(f"表 {table_name} 没有列信息,跳过")
            return

        # 根据SQLite表结构创建MySQL表
        self.create_mysql_table_from_sqlite(mysql_cur, table_name, columns_info)

        # 获取SQLite表的数据
        sqlite_cur.execute(f"SELECT * FROM `{table_name}`")
        rows = sqlite_cur.fetchall()

        if not rows:
            self.logger.info(f"表 {table_name} 没有数据")
            return

        # 准备插入语句
        column_names = [col[1] for col in columns_info]
        placeholders = ', '.join(['%s'] * len(column_names))

        # 使用 ON DUPLICATE KEY UPDATE 处理重复主键
        update_clause = ', '.join([f"`{col}`=VALUES(`{col}`)" for col in column_names])
        insert_sql = f"INSERT INTO `{table_name}` ({', '.join([f'`{col}`' for col in column_names])}) VALUES ({placeholders}) ON DUPLICATE KEY UPDATE {update_clause}"

        # 执行批量插入
        try:
            mysql_cur.executemany(insert_sql, rows)
            self.logger.info(f"成功插入或更新 {len(rows)} 条记录到表 {table_name}")
        except Error as e:
            self.logger.error(f"批量插入数据到表 {table_name} 失败: {e}")
            # 如果批量插入失败,尝试逐条插入找出具体哪条记录有问题
            successful_inserts = 0
            for i, row in enumerate(rows):
                try:
                    mysql_cur.execute(insert_sql, row)
                    successful_inserts += 1
                except Error as row_error:
                    self.logger.error(f"第 {i + 1} 条记录插入失败: {row_error}, 记录内容: {row}")

            self.logger.info(f"表 {table_name} 成功插入/更新 {successful_inserts}/{len(rows)} 条记录")

    def connect_databases(self):
        """
        连接两个数据库
        """
        # 连接 SQLite
        self.sqlite_conn = sqlite3.connect(self.sqlite_path)
        self.sqlite_cur = self.sqlite_conn.cursor()
        self.logger.info("成功连接到SQLite数据库")

        # 连接 MySQL
        self.mysql_conn = mysql.connector.connect(**self.mysql_config)
        self.mysql_cur = self.mysql_conn.cursor()
        self.logger.info("成功连接到MySQL数据库")

    def disconnect_databases(self):
        """
        断开数据库连接
        """
        if self.sqlite_conn:
            self.sqlite_conn.close()
            self.logger.info("SQLite连接已关闭")
        if self.mysql_conn:
            self.mysql_conn.close()
            self.logger.info("MySQL连接已关闭")

    def migrate_all_tables(self):
        """
        迁移所有表
        """
        try:
            self.connect_databases()

            # 获取SQLite中的所有表
            self.sqlite_cur.execute("SELECT name FROM sqlite_master WHERE type='table';")
            tables = self.sqlite_cur.fetchall()

            for table_tuple in tables:
                table_name = table_tuple[0]

                # 跳过SQLite系统表
                if table_name.startswith('sqlite_') or table_name == 'android_metadata':
                    self.logger.info(f"跳过系统表: {table_name}")
                    continue

                self.migrate_single_table(self.sqlite_cur, self.mysql_cur, table_name)

            # 提交事务
            self.mysql_conn.commit()
            self.logger.info("\n所有表迁移完成!")

        except Error as e:
            self.logger.error(f"数据库操作错误: {e}")
            if self.mysql_conn:
                self.mysql_conn.rollback()
        except Exception as e:
            self.logger.error(f"发生错误: {e}")
        finally:
            self.disconnect_databases()


def main():
    """
    主函数 - 数据库迁移入口
    """
    # SQLite 数据库路径
    sqlite_db_path = 'F:/lsq-DB/DB3K_508back.db3'

    # MySQL 配置
    mysql_config = {
        'host': '192.168.0.203',
        'port': 13306,
        'user': 'root',
        'password': 'root',
        'database': 'cmd-kaiyuan',
        'charset': 'utf8mb4',  # 推荐使用utf8mb4支持完整Unicode
        'autocommit': False
    }

    # 创建迁移实例并执行
    migrator = DatabaseMigration(sqlite_db_path, mysql_config)
    migrator.migrate_all_tables()


if __name__ == "__main__":
    main()