update sql.py

This commit is contained in:
zhaoyulin1117 2025-10-28 10:50:33 +08:00
parent 31d664f8e1
commit 8b4f36bdbc

View file

@ -23,308 +23,354 @@ https://www.sqlite.org/appfileformat.html
https://www.sqlite.org/sqlar.html
"""
import json
import logging
import os
import pathlib
import shutil
import sqlite3
import tempfile
# 导入必要的模块
import json # 导入 JSON 模块,用于处理 JSON 数据
import logging # 导入日志模块,用于记录程序运行时的信息
import os # 导入 os 模块,用于处理文件路径
import pathlib # 导入 pathlib 模块,用于处理文件路径
import shutil # 导入 shutil 模块,用于文件操作
import sqlite3 # 导入 sqlite3 模块,用于操作 SQLite 数据库
import tempfile # 导入 tempfile 模块,用于创建临时文件
from PyQt6 import QtGui
from beeref import constants
from beeref.items import BeePixmapItem, BeeErrorItem
from .errors import BeeFileIOError, IMG_LOADING_ERROR_MSG
from .schema import SCHEMA, USER_VERSION, MIGRATIONS, APPLICATION_ID
from PyQt6 import QtGui # 导入 PyQt6 模块,用于 GUI 编程
from beeref import constants # 导入 BeeRef 模块,用于定义常量
from beeref.items import BeePixmapItem, BeeErrorItem # 导入 BeeRef 模块,用于定义项
from .errors import BeeFileIOError, IMG_LOADING_ERROR_MSG # 导入 BeeRef 模块,用于定义错误
from .schema import SCHEMA, USER_VERSION, MIGRATIONS, APPLICATION_ID # 导入 BeeRef 模块,用于定义数据库模式
# 配置日志记录器
logger = logging.getLogger(__name__)
# 检查文件是否为bee文件
def is_bee_file(path):
"""Check whether the file at the given path is a bee file."""
return os.path.splitext(path)[1] == '.bee'
return os.path.splitext(path)[1] == '.bee' # 使用os.path.splitext分割文件名和扩展名
# 处理SQLite错误
def handle_sqlite_errors(func):
def wrapper(self, *args, **kwargs):
try:
func(self, *args, **kwargs)
except Exception as e:
logger.exception(f'Error while reading/writing {self.filename}')
try:
# 尝试执行原始函数
func(self, *args, **kwargs) # 执行函数
except Exception as e: # 捕获所有异常
logger.exception(f'Error while reading/writing {self.filename}')# 记录异常日志,包含文件名信息
try: # 尝试回滚事务
# Try to roll back transaction if there is any
if (hasattr(self, '_connection')
and self._connection.in_transaction):
self.ex('ROLLBACK')
logger.debug('Transaction rolled back')
except sqlite3.Error:
pass
self._close_connection()
if self.worker:
self.worker.finished.emit(self.filename, [str(e)])
else:
raise BeeFileIOError(msg=str(e), filename=self.filename) from e
return wrapper
if (hasattr(self, '_connection') # 检查是否有数据库连接属性
and self._connection.in_transaction): # 检查是否有未提交的事务
self.ex('ROLLBACK') # 执行SQL回滚命令
logger.debug('Transaction rolled back') # 记录回滚信息
except sqlite3.Error as e: # 捕获SQLite错误
pass # 如果回滚失败忽略SQLite错误
self._close_connection() # 关闭数据库连接
if self.worker: # 如果有工作线程,则向其发送完成信号并附带错误信息
self.worker.finished.emit(self.filename, [str(e)]) # 发射工作线程完成信号
else: # 如果没有工作线程
raise BeeFileIOError(msg=str(e), filename=self.filename) from e# 抛出 BeeFileIOError 异常
return wrapper # 返回包装后的函数
# 定义SQLiteIO类用于操作SQLite数据库文件
class SQLiteIO:
# 初始化SQLiteIO类
def __init__(self, filename, scene, create_new=False, readonly=False,
worker=None):
self.scene = scene
self.create_new = create_new
self.filename = filename
self.readonly = readonly
self.worker = worker
self.retry = False
self.scene = scene # 保存场景引用,用于添加或获取项目
self.create_new = create_new # 设置是否创建新文件的标志
self.filename = filename # 保存数据库文件名
self.readonly = readonly # 设置是否为只读模式
self.worker = worker # 保存工作线程引用,用于进度更新
self.retry = False # 初始化重试标志,用于错误恢复
# 析构函数
def __del__(self):
self._close_connection()
self._close_connection() # 在对象被销毁时调用,确保资源被正确释放
# 关闭数据库连接
def _close_connection(self):
if hasattr(self, '_connection'):
self._connection.close()
delattr(self, '_connection')
if hasattr(self, '_cursor'):
delattr(self, '_cursor')
if hasattr(self, '_tmpdir'):
self._tmpdir.cleanup()
delattr(self, '_tmpdir')
# 检查并关闭数据库连接
if hasattr(self, '_connection'): # 检查是否有数据库连接属性
self._connection.close() # 关闭数据库连接
delattr(self, '_connection') # 删除数据库连接属性
# 检查并删除游标引用
if hasattr(self, '_cursor'): # 检查是否有游标属性
delattr(self, '_cursor') # 删除游标属性
# 检查并清理临时目录
if hasattr(self, '_tmpdir'): # 检查是否有临时目录属性
self._tmpdir.cleanup() # 清理临时目录
delattr(self, '_tmpdir') # 删除临时目录属性
# 建立数据库连接
def _establish_connection(self):
if (self.create_new
and not self.readonly
and os.path.exists(self.filename)):
os.remove(self.filename)
# 如果需要创建新文件且文件已存在,则删除现有文件
if (self.create_new # 检查是否创建新文件
and not self.readonly # 检查是否为只读模式
and os.path.exists(self.filename)): # 检查文件是否已存在
os.remove(self.filename) # 删除已存在的文件
if self.create_new:
self.scene.clear_save_ids()
# 如果创建新文件清除场景中所有项目的保存I
if self.create_new: # 检查是否创建新文件
self.scene.clear_save_ids() # 清除场景中所有项目的保存ID
uri = pathlib.Path(self.filename).resolve().as_uri()
if self.readonly:
uri = f'{uri}?mode=rw'
self._connection = sqlite3.connect(uri, uri=True)
self._cursor = self.connection.cursor()
if not self.create_new:
uri = pathlib.Path(self.filename).resolve().as_uri()# 将文件路径转换为URI格式确保跨平台兼容性
if self.readonly: # 检查是否为只读模式
uri = f'{uri}?mode=rw' # 如果是只读模式,添加查询参数以指定为读写模式
self._connection = sqlite3.connect(uri, uri=True) # 建立数据库连接使用URI格式
self._cursor = self._connection.cursor() # 创建游标对象用于执行SQL语句
# 如果不是创建新文件,则尝试迁移数据库
if not self.create_new: # 检查是否不是创建新文件
try:
self._migrate()
except Exception:
self._migrate() # 尝试迁移数据库
except Exception as e: # 捕获所有异常
# Updating a file failed; try creating it from scratch instead
logger.exception('Error migrating bee file')
self.create_new = True
self._establish_connection()
logger.exception('Error migrating bee file: %s', e) # 记录异常日志,包含异常信息
self.create_new = True # 标记为创建新文件
self._establish_connection() # 重新建立数据库连接
# 迁移数据库
def _migrate(self):
"""Migrate database if necessary."""
version = self.fetchone('PRAGMA user_version')[0]
logger.debug(f'Found bee file version: {version}')
if version >= USER_VERSION:
logger.debug('Version ok; no migrations necessary')
return
version = self.fetchone('PRAGMA user_version')[0] # 获取数据库用户版本号
logger.debug(f'Found bee file version: {version}') # 记录数据库版本号
if version >= USER_VERSION: # 检查数据库版本号是否大于等于用户版本号
logger.debug('Version ok; no migrations necessary') # 如果版本号大于等于用户版本号,无需迁移
return # 如果版本号大于等于用户版本号,无需迁移
# 如果版本已经是最新的,不需要迁移
if self.readonly:
try:
# See whether file is writable so we can migrate it directly
self.ex('PRAGMA application_id=%s' % APPLICATION_ID)
except sqlite3.Error:
logger.debug('File not writable; use temporary copy instead')
self._connection.close()
self._tmpdir = tempfile.TemporaryDirectory(
prefix=constants.APPNAME)
tmpname = os.path.join(self._tmpdir.name, 'mig.bee')
shutil.copyfile(self.filename, tmpname)
self._connection = sqlite3.connect(tmpname)
self._cursor = self.connection.cursor()
self.ex('PRAGMA application_id=%s' % APPLICATION_ID) # 设置应用程序ID确保数据库与应用程序兼容
except sqlite3.Error as e: # 捕获 SQLite 错误异常
logger.debug('File not writable; use temporary copy instead')# 记录文件不可写日志,建议使用临时副本
self._connection.close() # 关闭数据库连接
self._tmpdir = tempfile.TemporaryDirectory( # 创建临时目录,用于存储迁移后的数据库文件
prefix=constants.APPNAME) # 设置临时目录前缀,确保与应用程序名称一致
tmpname = os.path.join(self._tmpdir.name, 'mig.bee') # 构建临时数据库文件名
shutil.copyfile(self.filename, tmpname) # 复制原始数据库文件到临时目录
self._connection = sqlite3.connect(tmpname) # 连接到临时数据库文件
self._cursor = self.connection.cursor() # 创建游标对象用于执行SQL语句
self.ex('BEGIN TRANSACTION')
for i in range(version, USER_VERSION):
logger.debug(f'Migrating from version {i} to {i + 1}...')
for migration in MIGRATIONS[i + 1]:
self.ex(migration)
self.write_meta()
self.connection.commit()
logger.debug('Migration finished')
self.ex('BEGIN TRANSACTION') # 开始事务,确保迁移的原子性
for i in range(version, USER_VERSION): # 遍历需要迁移的版本号范围
logger.debug(f'Migrating from version {i} to {i + 1}...') # 记录迁移信息,包含当前版本号和目标版本号
for migration in MIGRATIONS[i + 1]: # 遍历当前版本号对应的迁移SQL语句列表
self.ex(migration) # 执行迁移SQL语句
self.write_meta() # 写入数据库元数据包括应用程序ID和用户版本号
self.connection.commit() # 提交事务,确保所有迁移操作都被永久保存
logger.debug('Migration finished') # 记录迁移完成日志
@property
# 获取数据库连接属性
def connection(self):
if not hasattr(self, '_connection'):
self._establish_connection()
return self._connection
if not hasattr(self, '_connection'): # 检查是否没有数据库连接属性
self._establish_connection() # 如果没有数据库连接属性,建立数据库连接
return self._connection # 返回数据库连接属性
@property
# 获取数据库游标属性
def cursor(self):
if not hasattr(self, '_cursor'):
self._establish_connection()
return self._cursor
if not hasattr(self, '_cursor'): # 检查是否没有数据库游标属性
self._establish_connection() # 如果没有数据库游标属性,建立数据库连接
return self._cursor # 返回数据库游标属性
# 执行SQL语句返回执行结果
def ex(self, *args, **kwargs):
return self.cursor.execute(*args, **kwargs)
# 执行批量SQL语句返回执行结果
def exmany(self, *args, **kwargs):
return self.cursor.executemany(*args, **kwargs)
# 从数据库游标中获取一条记录,返回记录元组
def fetchone(self, *args, **kwargs):
self.ex(*args, **kwargs)
return self.cursor.fetchone()
self.ex(*args, **kwargs) # 执行SQL语句传入参数和关键字参数
return self.cursor.fetchone() # 返回查询结果的第一行
# 从数据库游标中获取所有记录,返回记录列表
def fetchall(self, *args, **kwargs):
self.ex(*args, **kwargs)
return self.cursor.fetchall()
self.ex(*args, **kwargs) # 执行SQL语句传入参数和关键字参数
return self.cursor.fetchall() # 从游标中获取所有记录,返回记录列表
# 写入数据库元数据包括应用程序ID和用户版本号
def write_meta(self):
self.ex('PRAGMA application_id=%s' % APPLICATION_ID)
self.ex('PRAGMA user_version=%s' % USER_VERSION)
self.ex('PRAGMA foreign_keys=ON')
self.ex('PRAGMA application_id=%s' % APPLICATION_ID) # 设置应用程序ID确保数据库与应用程序兼容
self.ex('PRAGMA user_version=%s' % USER_VERSION) # 设置用户版本,用于版本控制
self.ex('PRAGMA foreign_keys=ON') # 启用外键约束,确保数据完整性
# 创建新文件时创建数据库架构
def create_schema_on_new(self):
if self.create_new:
self.write_meta()
for schema in SCHEMA:
self.ex(schema)
if self.create_new: # 仅在创建新文件时执行
self.write_meta() # 写入数据库元数据
for schema in SCHEMA: # 执行所有架构创建SQL语句
self.ex(schema) # 执行架构创建SQL语句
# 从数据库中读取所有项,包括图片和文本项
@handle_sqlite_errors
def read(self):
# 读取所有图像项目及其关联的二进制数据
rows = self.fetchall(
'SELECT items.id, type, x, y, z, scale, rotation, flip, '
'items.data, sqlar.data '
'FROM sqlar JOIN items on sqlar.item_id = items.id')
# Avoid OUTER JOIN for performance reasons; fetch text items
# separately instead
# 读取所有文本项目
rows.extend(self.fetchall(
'SELECT items.id, type, x, y, z, scale, rotation, flip, '
' items.data, null as data '
'FROM items '
'WHERE items.type = "text"'))
if self.worker:
self.worker.begin_processing.emit(len(rows))
if self.worker: # 如果有工作线程
self.worker.begin_processing.emit(len(rows)) # 发送开始处理信号,包含项数
# 遍历所有读取的项目数据
for i, row in enumerate(rows):
# 构建项目数据字典
data = {
'save_id': row[0],
'type': row[1],
'x': row[2],
'y': row[3],
'z': row[4],
'scale': row[5],
'rotation': row[6],
'flip': row[7],
'data': json.loads(row[8]),
'save_id': row[0], # 项目ID
'type': row[1], # 项目类型
'x': row[2], # X坐标
'y': row[3], # Y坐标
'z': row[4], # Z坐标
'scale': row[5], # 缩放比例
'rotation': row[6], # 旋转角度
'flip': row[7], # 是否翻转
'data': json.loads(row[8]), # 项目数据
}
if data['type'] == 'pixmap':
item = BeePixmapItem(QtGui.QImage())
item.pixmap_from_bytes(row[9])
# 处理像素图项目
if data['type'] == 'pixmap': # 如果项目类型为像素图
item = BeePixmapItem(QtGui.QImage()) # 创建空的像素图项目
item.pixmap_from_bytes(row[9]) # 从二进制数据创建像素图
# 检查图像是否加载成功
if item.pixmap().isNull():
item = data['data']['text'] = (
# 如果图像加载失败,创建错误项目
item = data['data']['text'] = (
f'Image could not be loaded: {item.filename}\n'
+ IMG_LOADING_ERROR_MSG)
data['type'] = BeeErrorItem.TYPE
data['item'] = item
self.scene.add_item_later(data)
data['type'] = BeeErrorItem.TYPE # 将项目类型设置为错误项类型
data['item'] = item # 将项目添加到数据字典中
self.scene.add_item_later(data) # 稍后将项目添加到场景中
# 如果有工作线程,更新进度并检查是否取消操作
if self.worker:
logger.trace(f'Emit progress: {i}')
self.worker.progress.emit(i)
if self.worker.canceled:
self.worker.finished.emit('', [])
return
logger.trace(f'Emit progress: {i}') # 记录进度信息
self.worker.progress.emit(i) # 发送进度信号,包含当前项索引
if self.worker.canceled: # 如果工作线程已取消
self.worker.finished.emit('', []) # 发送完成信号,包含空字符串和空列表
return # 退出循环,停止处理
# Give main thread time to process items:
self.worker.msleep(10)
if self.worker:
self.worker.finished.emit(self.filename, [])
self.worker.msleep(10) # 暂停10毫秒允许主线程处理事件
if self.worker: # 如果有工作线程
self.worker.finished.emit(self.filename, [])# 发送完成信号,包含文件名和空列表
@handle_sqlite_errors
# 写入数据库中的所有项,包括图片和文本项
def write(self):
if self.readonly:
raise sqlite3.OperationalError(
# 检查是否为只读模式
if self.readonly: # 如果数据库为只读模式
raise sqlite3.OperationalError( # 抛出操作错误异常
'Attempt to write to a readonly database')
try:
self.create_schema_on_new()
self.write_data()
except Exception:
if self.retry:
try: # 尝试执行写入操作
self.create_schema_on_new() # 创建新文件时创建数据库架构
self.write_data() # 写入所有数据
except Exception as e: # 捕获所有异常
#处理写入错误
if self.retry: # 如果允许重试
# Trying to recover failed
raise
raise # 如果已经重试过一次,则放弃并抛出异常
else:
self.retry = True
self.retry = True # 第一次失败后,标记为需要重试
# Try creating file from scratch and save again
logger.exception(
f'Updating to existing file {self.filename} failed')
self.create_new = True
self._close_connection()
self.write()
logger.exception( # 尝试从头创建文件并再次保存
f'Updating to existing file {self.filename} failed') # 记录更新失败的异常信息
self.create_new = True # 标记为创建新文件
self._close_connection() # 关闭现有连接,以便重新建立连接
self.write() # 递归调用写入方法,尝试重新创建文件并保存
# 写入场景数据到数据库
def write_data(self):
to_delete = {row[0] for row in self.fetchall('SELECT id from ITEMS')}
to_delete = {row[0] for row in self.fetchall('SELECT id from ITEMS')} # 获取所有需要删除的项目ID
# We don't want to touch existing items that are displayed as errors:
keep = {item.original_save_id
for item in self.scene.items_by_type(BeeErrorItem.TYPE)}
logger.debug(f'Not saving error items: {keep}')
to_delete = to_delete - keep
for item in self.scene.items_by_type(BeeErrorItem.TYPE)} # 获取所有错误项的原始保存ID
logger.debug(f'Not saving error items: {keep}') # 记录不保存错误项的ID
to_delete = to_delete - keep # 从待删除列表中移除需要保留的项目
# 获取需要保存的项目列表
to_save = list(self.scene.items_for_save())
# 如果有工作线程,发送开始处理信号
if self.worker:
self.worker.begin_processing.emit(len(to_save))
# 遍历所有需要保存的项目
for i, item in enumerate(to_save):
logger.debug(f'Saving {item} with id {item.save_id}')
if item.save_id:
self.update_item(item)
to_delete.remove(item.save_id)
else:
self.insert_item(item)
logger.debug(f'Saving {item} with id {item.save_id}') # 记录正在保存的项目信息
if item.save_id: # 如果项目已存在数据库中
self.update_item(item) # 更新项目数据
to_delete.remove(item.save_id) # 从待删除列表中移除已更新的项目ID
else: # 如果项目不在数据库中
self.insert_item(item) # 插入新项目到数据库中
# 如果有工作线程,发送进度信号,包含当前项索引
if self.worker:
self.worker.progress.emit(i)
# 如果工作线程已取消,跳出循环,停止处理
if self.worker.canceled:
break
self.delete_items(to_delete)
self.ex('VACUUM')
self.connection.commit()
if self.worker:
self.worker.finished.emit(self.filename, [])
self.delete_items(to_delete) # 删除不再需要的项目
self.ex('VACUUM') # 执行VACUUM命令优化数据库空间
self.connection.commit() # 提交事务,确保所有更改生效
if self.worker: # 如果有工作线程
self.worker.finished.emit(self.filename, []) # 发送完成信号,包含文件名和空列表
# 从数据库中删除指定的项目
def delete_items(self, to_delete):
to_delete = [(pk,) for pk in to_delete]
self.exmany('DELETE FROM items WHERE id=?', to_delete)
self.exmany('DELETE FROM sqlar WHERE item_id=?', to_delete)
self.connection.commit()
to_delete = [(pk,) for pk in to_delete] # 将待删除项目ID转换为元组列表
self.exmany('DELETE FROM items WHERE id=?', to_delete) # 执行批量删除项目的SQL语句
self.exmany('DELETE FROM sqlar WHERE item_id=?', to_delete) # 执行批量删除关联文件的SQL语句
self.connection.commit() # 提交事务,确保所有更改生效
# 向数据库中插入新项目
def insert_item(self, item):
# 插入项目基本信息到items表
self.ex(
'INSERT INTO items (type, x, y, z, scale, rotation, flip, '
'data) '
'VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
(item.TYPE, item.pos().x(), item.pos().y(), item.zValue(),
item.scale(), item.rotation(), item.flip(),
json.dumps(item.get_extra_save_data())))
item.save_id = self.cursor.lastrowid
(item.TYPE, item.pos().x(), item.pos().y(), item.zValue(), # 插入项目的类型、位置、Z值、缩放比例、旋转角度和翻转状态
item.scale(), item.rotation(), item.flip(), # 插入项目的缩放比例、旋转角度和翻转状态
json.dumps(item.get_extra_save_data()))) # 插入项目的额外保存数据
item.save_id = self.cursor.lastrowid # 获取新插入项目的ID
# 如果项目有像素图数据,也保存它
if hasattr(item, 'pixmap_to_bytes'):
pixmap, imgformat = item.pixmap_to_bytes()
name = item.get_filename_for_export(imgformat)
pixmap, imgformat = item.pixmap_to_bytes() # 获取像素图的二进制数据和图像格式
name = item.get_filename_for_export(imgformat) # 获取导出文件名
# 将二进制数据插入到sqlar表
self.ex(
'INSERT INTO sqlar (item_id, name, mode, sz, data) '
'VALUES (?, ?, ?, ?, ?)',
(item.save_id, name, 0o644, len(pixmap), pixmap))
self.connection.commit()
(item.save_id, name, 0o644, len(pixmap), pixmap)) # 插入像素图数据到sqlar表
self.connection.commit() # 提交事务,确保所有更改生效
# 更新数据库中的项目数据
def update_item(self, item):
"""Update item data.
We only update the item data, not the pixmap data, as pixmap
data never changes and is also time-consuming to save.
"""
# 更新items表中的项目数据
self.ex(
'UPDATE items SET x=?, y=?, z=?, scale=?, rotation=?, flip=?, '
'data=? '
'WHERE id=?',
(item.pos().x(), item.pos().y(), item.zValue(), item.scale(),
item.rotation(), item.flip(),
json.dumps(item.get_extra_save_data()),
item.save_id))
self.connection.commit()
(item.pos().x(), item.pos().y(), item.zValue(), item.scale(), # 更新项目的位置、Z值、缩放比例
item.rotation(), item.flip(), # 更新项目的旋转角度和翻转状态
json.dumps(item.get_extra_save_data()), # 更新项目的额外保存数据
item.save_id)) # 更新项目的ID
self.connection.commit() # 提交事务,确保所有更改生效