mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-09 15:19:54 +08:00
插件配置此前寄存在 systemconfig 的 plugin.<实例ID> 单键下,实例描述符则整份挤在
PluginInstances 这一个 JSON 键里,两者都不成立:
- plugin.<ID> 是裸字符串 key,而仓库规则本身禁止用裸字符串做 SystemConfig key,
只允许先定义 SystemConfigKey 枚举项;插件 ID 由用户安装决定,永远枚举不出常量。
条目数随安装量增长,混在系统设置表里会把主程序自己的设置项淹掉。
- 那个键存的其实是实例 ID,表里没有任何一列说得出它属于哪个插件,想列出某个插件
的全部实例配置只能靠字符串前缀去猜。
- 实例描述符整份读出再整份写回,改一个分身要重写全部分身。
改为 plugininstance 一实例一行:instance_id 与 source_plugin_id 构成身份,本体与
分身由两者是否相等派生而不设模式列——模式列会是这个等式的冗余副本,两者一旦失步
同一行就会在不同读取口被判成不同角色。展示信息与业务参数同存一行,属于同一个
生命周期,分表只会让建分身、删分身退化成两张表之间的协调问题。
读写路由落在 SystemConfigOper.get/set/delete,而不是在各个调用方各改一处:第三方
插件可能直接用 self.systemconfig.get("plugin.xxx") 读写自己的配置,只改
PluginConfigStore 与 _PluginBase.get_config/update_config 必然漏掉它们。路由只做
前缀识别,插入、更新与空本体行回收都委托 PluginInstanceOper,不在配置层重抄一遍。
PluginInstances 旧键迁移后不删,留作回滚依据,并以
SystemConfigKey.PluginInstancesImported 标志防止重复导入;判据不能是「表当前为空」,
否则用户把分身全部删光后,下次启动会把它们整批导回来。
迁移链:b2d4f6a8c1e3 -> 281965691a20(3.0.34 建表并搬描述符)
-> c4e1a7b9d2f6(3.0.35 加 config_data 并搬 plugin.* 配置)。
依赖基线与启动模块数随两个新模块重算,架构文档与数据库技能表目录同步登记新表。
179 lines
6.8 KiB
Python
179 lines
6.8 KiB
Python
"""插件配置迁入插件实例表的 Alembic 迁移测试。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import json
|
|
|
|
import sqlalchemy as sa
|
|
from alembic.migration import MigrationContext
|
|
from alembic.operations import Operations
|
|
|
|
MIGRATION_MODULE = "database.versions.c4e1a7b9d2f6_3_0_35"
|
|
|
|
|
|
def _bind_migration(monkeypatch, connection):
|
|
"""把迁移绑定到隔离数据库连接。"""
|
|
migration = importlib.import_module(MIGRATION_MODULE)
|
|
context = MigrationContext.configure(connection)
|
|
monkeypatch.setattr(migration, "op", Operations(context))
|
|
return migration
|
|
|
|
|
|
def _create_legacy_schema(connection: sa.engine.Connection) -> None:
|
|
"""建出迁移前的系统设置表与实例表,并写入两类数据各若干条。
|
|
|
|
实例表按上一条迁移 281965691a20 建出的形状,不含业务参数列。
|
|
"""
|
|
sa.Table(
|
|
"systemconfig",
|
|
sa.MetaData(),
|
|
sa.Column("id", sa.Integer(), primary_key=True),
|
|
sa.Column("key", sa.String(), nullable=False),
|
|
sa.Column("value", sa.JSON()),
|
|
).create(connection)
|
|
connection.execute(
|
|
sa.text("INSERT INTO systemconfig (key, value) VALUES (:k, :v)"),
|
|
[
|
|
{"k": "plugin.DemoPlugin", "v": json.dumps({"enable": True, "token": "keep"})},
|
|
{"k": "plugin.DemoPluginwork", "v": json.dumps({"enable": False})},
|
|
{"k": "UserInstalledPlugins", "v": json.dumps(["DemoPlugin", "BareInstalled"])},
|
|
],
|
|
)
|
|
|
|
sa.Table(
|
|
"plugininstance",
|
|
sa.MetaData(),
|
|
sa.Column("id", sa.Integer(), primary_key=True),
|
|
sa.Column("instance_id", sa.String(128), nullable=False, unique=True),
|
|
sa.Column("source_plugin_id", sa.String(128), nullable=False),
|
|
sa.Column("plugin_name", sa.String(255)),
|
|
sa.Column("plugin_desc", sa.String(255)),
|
|
sa.Column("plugin_icon", sa.String(255)),
|
|
sa.Column("created_at", sa.String(40), nullable=False),
|
|
sa.Column("updated_at", sa.String(40), nullable=False),
|
|
).create(connection)
|
|
connection.execute(
|
|
sa.text(
|
|
"INSERT INTO plugininstance "
|
|
"(instance_id, source_plugin_id, plugin_name, created_at, updated_at) "
|
|
"VALUES (:iid, :sid, :name, 'x', 'x')"
|
|
),
|
|
[{"iid": "DemoPluginwork", "sid": "DemoPlugin", "name": "分身甲"}],
|
|
)
|
|
|
|
|
|
def _instance_rows(connection: sa.engine.Connection) -> dict:
|
|
"""读取实例表内容,兼容驱动把 JSON 列返回为字符串的情形。"""
|
|
rows = connection.execute(
|
|
sa.text(
|
|
"SELECT instance_id, source_plugin_id, plugin_name, config_data "
|
|
"FROM plugininstance"
|
|
)
|
|
).fetchall()
|
|
return {
|
|
row[0]: {
|
|
"source_plugin_id": row[1],
|
|
"plugin_name": row[2],
|
|
"config_data": json.loads(row[3]) if isinstance(row[3], str) else row[3],
|
|
}
|
|
for row in rows
|
|
}
|
|
|
|
|
|
def _system_keys(connection: sa.engine.Connection) -> set:
|
|
"""读取系统设置表现存的键。"""
|
|
return {
|
|
row[0]
|
|
for row in connection.execute(sa.text("SELECT key FROM systemconfig")).fetchall()
|
|
}
|
|
|
|
|
|
def _columns(connection: sa.engine.Connection) -> set:
|
|
"""读取实例表当前列名。"""
|
|
return {column["name"] for column in sa.inspect(connection).get_columns("plugininstance")}
|
|
|
|
|
|
def test_migration_moves_plugin_config_onto_the_instance_rows(monkeypatch):
|
|
"""插件配置搬到实例行上,原行一并删除,真·系统设置留在原地。"""
|
|
engine = sa.create_engine("sqlite://")
|
|
with engine.connect() as connection:
|
|
_create_legacy_schema(connection)
|
|
migration = _bind_migration(monkeypatch, connection)
|
|
|
|
migration.upgrade()
|
|
|
|
rows = _instance_rows(connection)
|
|
assert rows["DemoPlugin"]["config_data"] == {"enable": True, "token": "keep"}
|
|
# 本体没有实例行时按自身建出,归属就是它自己
|
|
assert rows["DemoPlugin"]["source_plugin_id"] == "DemoPlugin"
|
|
# 分身已有实例行,配置并到那一行上而不是另起一行
|
|
assert rows["DemoPluginwork"]["config_data"] == {"enable": False}
|
|
assert rows["DemoPluginwork"]["source_plugin_id"] == "DemoPlugin"
|
|
# 同一份配置不能在两张表里各留一份,否则读取端按哪个都不对
|
|
assert _system_keys(connection) == {"UserInstalledPlugins"}
|
|
|
|
|
|
def test_migration_keeps_the_display_fields_already_on_the_instance(monkeypatch):
|
|
"""展示信息本就长在实例行上,补列搬配置时不得把它丢掉。"""
|
|
engine = sa.create_engine("sqlite://")
|
|
with engine.connect() as connection:
|
|
_create_legacy_schema(connection)
|
|
migration = _bind_migration(monkeypatch, connection)
|
|
|
|
migration.upgrade()
|
|
|
|
assert _instance_rows(connection)["DemoPluginwork"]["plugin_name"] == "分身甲"
|
|
|
|
|
|
def test_migration_does_not_backfill_rows_for_plugins_without_config(monkeypatch):
|
|
"""装了却从没存过配置的插件不该凭空多出一行:没有配置就没有要搬的东西。"""
|
|
engine = sa.create_engine("sqlite://")
|
|
with engine.connect() as connection:
|
|
_create_legacy_schema(connection)
|
|
migration = _bind_migration(monkeypatch, connection)
|
|
|
|
migration.upgrade()
|
|
|
|
assert set(_instance_rows(connection)) == {"DemoPlugin", "DemoPluginwork"}
|
|
|
|
|
|
def test_migration_is_idempotent_on_repeated_upgrade(monkeypatch):
|
|
"""重复升级不得因唯一键撞车而失败,也不得把配置复制成两份。"""
|
|
engine = sa.create_engine("sqlite://")
|
|
with engine.connect() as connection:
|
|
_create_legacy_schema(connection)
|
|
migration = _bind_migration(monkeypatch, connection)
|
|
|
|
migration.upgrade()
|
|
migration.upgrade()
|
|
|
|
rows = _instance_rows(connection)
|
|
assert len(rows) == 2
|
|
assert rows["DemoPlugin"]["config_data"] == {"enable": True, "token": "keep"}
|
|
|
|
|
|
def test_downgrade_puts_plugin_config_back_into_system_settings(monkeypatch):
|
|
"""回滚把配置搬回原键并移除新增列,回退路径上不丢配置。"""
|
|
engine = sa.create_engine("sqlite://")
|
|
with engine.connect() as connection:
|
|
_create_legacy_schema(connection)
|
|
migration = _bind_migration(monkeypatch, connection)
|
|
migration.upgrade()
|
|
|
|
migration.downgrade()
|
|
|
|
assert _system_keys(connection) == {
|
|
"UserInstalledPlugins",
|
|
"plugin.DemoPlugin",
|
|
"plugin.DemoPluginwork",
|
|
}
|
|
assert "config_data" not in _columns(connection)
|
|
# 实例行本身留着:回滚只把配置搬走,不该顺手删掉分身
|
|
assert set(
|
|
row[0]
|
|
for row in connection.execute(
|
|
sa.text("SELECT instance_id FROM plugininstance")
|
|
).fetchall()
|
|
) == {"DemoPlugin", "DemoPluginwork"}
|