mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-02 05:17:23 +08:00
feat(plugin): 开放插件自有数据库声明与句柄接口
_PluginBase 新增 get_database_models()/get_database_migrations() 声明钩子 与 get_database() 句柄入口,按类名(含分身)解析插件标识; app/sdk/database.py 导出 plugin_declarative_base 与 PluginDatabaseHandle。 docs/rules/10-data-and-persistent.md 补充 Plugin-Owned Databases 小节, 说明 db_query/db_update 的自动兜底仍绑定宿主会话、需显式传入插件会话。
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Dict, Tuple, Optional, Type
|
||||
from typing import Any, List, Dict, Tuple, Optional, Type, Union
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.core.config import settings
|
||||
from app.core.event import EventManager
|
||||
from app.db.oper.plugindata import PluginDataOper
|
||||
from app.db.oper.systemconfig import SystemConfigOper
|
||||
from app.db.plugin.container import PluginDatabaseHandle
|
||||
from app.db.plugin.registry import get_database as get_plugin_database
|
||||
from app.helper.message import MessageHelper
|
||||
from app.schemas import Notification, NotificationType, MessageChannel
|
||||
|
||||
@@ -238,6 +240,29 @@ class _PluginBase(metaclass=ABCMeta):
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_database_models(self) -> Optional[List[Type]]:
|
||||
"""
|
||||
声明插件自有数据库中的模型
|
||||
[ModelClass1, ModelClass2, ...]
|
||||
|
||||
对模型类的要求:
|
||||
1、模型类必须继承 app.sdk.database.plugin_declarative_base() 产出的基类
|
||||
2、同一插件的模型应继承同一个基类,它们的表在插件启动时一并建立
|
||||
3、同时声明了迁移目录时本声明被忽略,改由 alembic 建表
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_database_migrations(self) -> Optional[Union[str, Path]]:
|
||||
"""
|
||||
声明插件自有数据库的 Alembic 迁移脚本目录
|
||||
目录须符合 Alembic script_location 布局
|
||||
|
||||
声明后插件启动时执行 alembic upgrade head,不再按 get_database_models() 建表。
|
||||
PostgreSQL 下迁移脚本的 env.py 必须从 context.config.attributes["connection"]
|
||||
取用宿主传入的连接,否则迁移会落在 public schema 而不是本插件的 schema。
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def stop_service(self):
|
||||
"""
|
||||
@@ -275,6 +300,16 @@ class _PluginBase(metaclass=ABCMeta):
|
||||
data_path.mkdir(parents=True)
|
||||
return data_path
|
||||
|
||||
def get_database(self, plugin_id: Optional[str] = None) -> PluginDatabaseHandle:
|
||||
"""
|
||||
获取插件自有数据库句柄,用于取会话读写插件自有表
|
||||
句柄不存在时按需建立,不要求先声明模型
|
||||
:param plugin_id: 插件ID
|
||||
"""
|
||||
if not plugin_id:
|
||||
plugin_id = self.__class__.__name__
|
||||
return get_plugin_database(plugin_id)
|
||||
|
||||
def save_data(self, key: str, value: Any, plugin_id: Optional[str] = None):
|
||||
"""
|
||||
保存插件数据
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
"""插件可使用的数据库备份只读门面。"""
|
||||
"""插件可使用的数据库备份只读门面和自有数据库入口。"""
|
||||
|
||||
from app.application.backup import BackupArtifact, BackupVerification
|
||||
from app.application.database import get_database_governance as _get_database_governance
|
||||
from app.db.plugin.base import plugin_declarative_base
|
||||
from app.db.plugin.container import PluginDatabaseHandle
|
||||
|
||||
__all__ = [
|
||||
"BackupArtifact",
|
||||
"BackupVerification",
|
||||
"PluginDatabaseHandle",
|
||||
"create_backup",
|
||||
"list_backups",
|
||||
"plugin_declarative_base",
|
||||
"verify_backup",
|
||||
]
|
||||
|
||||
|
||||
@@ -242,6 +242,45 @@ oper.delete(sid=1) # Delete by key
|
||||
|
||||
---
|
||||
|
||||
## Plugin-Owned Databases
|
||||
|
||||
Plugins that need SQL storage beyond `save_data`/`get_data` own an isolated
|
||||
database rather than a table inside the host database. The framework lives in
|
||||
`app/db/plugin/`: SQLite deploys one file per plugin at
|
||||
`PLUGIN_DATA_PATH/<PluginId>/plugin.db`; PostgreSQL deploys one schema
|
||||
`plugin_<pluginid>` that reuses the host engine via `schema_translate_map`.
|
||||
|
||||
A plugin declares its schema with `get_database_models()` and/or
|
||||
`get_database_migrations()`. The host pulls both hooks once, right after
|
||||
`init_plugin()` returns, and creates nothing when both are empty — most
|
||||
plugins never pay for this framework. A declared migrations directory takes
|
||||
precedence over declared models and is applied with `alembic upgrade head`.
|
||||
|
||||
Models must inherit `app.sdk.database.plugin_declarative_base()`, which mints
|
||||
a fresh `MetaData` per call so a plugin's tables never collide with
|
||||
`app.db.base.Base.metadata` or with another plugin's same-named tables. At
|
||||
runtime, `_PluginBase.get_database()` returns a `PluginDatabaseHandle` for
|
||||
opening sessions against the plugin's own engine.
|
||||
|
||||
Lifecycle is strictly ensure/release/destroy: plugin start calls `ensure`
|
||||
after `init_plugin()`; stop, reload and remove call `release` only, which
|
||||
disposes the connection pool but never touches data. Only resetting a
|
||||
plugin's data or uninstalling a clone/virtual instance calls `destroy`
|
||||
(delete the SQLite file and its `-wal`/`-shm` sidecars, or `DROP SCHEMA
|
||||
... CASCADE`). Stopping or uninstalling an ordinary plugin never destroys its
|
||||
database, mirroring the existing `plugindata` retention semantics.
|
||||
|
||||
`db_query` / `db_update` keep their existing automatic-Session fallback bound
|
||||
to the **host** `ScopedSession()`; they are not aware of plugin-owned
|
||||
databases. A plugin operating on its own tables must pass
|
||||
`handle.session()` explicitly — the decorators accept any `Session` argument
|
||||
supplied by the caller.
|
||||
|
||||
Plugin databases never participate in host Alembic (`database/versions/`)
|
||||
and their tables never register on `app.db.base.Base.metadata`.
|
||||
|
||||
---
|
||||
|
||||
## SystemConfig — Runtime Configuration
|
||||
|
||||
**Purpose:** Runtime business configuration that is user-editable, persisted in the database, and survives application restarts.
|
||||
@@ -362,4 +401,4 @@ can be accepted only once without Application knowing the configured backend.
|
||||
- `settings.API_TOKEN` and other secret fields must not be included in log output or API responses.
|
||||
- The `config list --show-secrets` flag exists specifically to gate secret visibility in the CLI.
|
||||
|
||||
*Last Updated: 2026-08-28*
|
||||
*Last Updated: 2026-09-01*
|
||||
|
||||
172
tests/test_plugin_database_hooks.py
Normal file
172
tests/test_plugin_database_hooks.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""_PluginBase 自有数据库钩子与插件 SDK 数据库入口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
import app.db.plugin.locator as locator_module
|
||||
import app.db.plugin.registry as registry_module
|
||||
from app.db.decorators import db_query, db_update
|
||||
from app.db.plugin.base import plugin_declarative_base
|
||||
from app.plugins import _PluginBase
|
||||
|
||||
|
||||
class _SamplePlugin(_PluginBase):
|
||||
"""只实现最小生命周期合同的插件测试替身。"""
|
||||
|
||||
plugin_name = "示例插件"
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
"""接受宿主初始化配置。"""
|
||||
|
||||
def get_state(self) -> bool:
|
||||
"""保持插件为启用状态。"""
|
||||
return True
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
"""不注册任何 API。"""
|
||||
return []
|
||||
|
||||
def get_form(self) -> Tuple[Optional[List[dict]], Dict[str, Any]]:
|
||||
"""不提供配置页面。"""
|
||||
return None, {}
|
||||
|
||||
def get_page(self) -> Optional[List[dict]]:
|
||||
"""不提供详情页面。"""
|
||||
return None
|
||||
|
||||
def stop_service(self):
|
||||
"""无后台资源需要停止。"""
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_plugin_databases():
|
||||
"""快照插件数据库句柄注册表,用例结束后释放残留句柄并还原快照。"""
|
||||
snapshot = dict(registry_module._handles)
|
||||
registry_module._handles.clear()
|
||||
yield
|
||||
for plugin_id in list(registry_module._handles):
|
||||
registry_module.release_database(plugin_id)
|
||||
registry_module._handles.clear()
|
||||
registry_module._handles.update(snapshot)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugin_data_root(tmp_path, monkeypatch) -> Path:
|
||||
"""把插件数据库文件隔离到进程私有的临时目录。"""
|
||||
root = tmp_path / "plugins"
|
||||
monkeypatch.setattr(
|
||||
locator_module,
|
||||
"get_runtime_setting",
|
||||
lambda key, default=None: root if key == "PLUGIN_DATA_PATH" else default,
|
||||
)
|
||||
return root
|
||||
|
||||
|
||||
def test_database_declaration_hooks_default_to_no_declaration():
|
||||
"""未重写声明钩子的插件视为既未声明模型也未声明迁移目录。"""
|
||||
plugin = _SamplePlugin()
|
||||
assert plugin.get_database_models() is None
|
||||
assert plugin.get_database_migrations() is None
|
||||
|
||||
|
||||
def test_get_database_uses_the_plugin_class_name(plugin_data_root):
|
||||
"""未显式传入插件ID时,句柄按插件类名解析,与 get_data_path() 目录约定一致。"""
|
||||
plugin = _SamplePlugin()
|
||||
|
||||
handle = plugin.get_database()
|
||||
|
||||
assert handle.plugin_id == "_SamplePlugin"
|
||||
assert handle.db_path == plugin_data_root / "_SamplePlugin" / "plugin.db"
|
||||
|
||||
|
||||
def test_get_database_honours_an_explicit_plugin_id(plugin_data_root):
|
||||
"""显式传入插件ID时按该ID解析句柄,不回退到类名。"""
|
||||
plugin = _SamplePlugin()
|
||||
|
||||
handle = plugin.get_database(plugin_id="OtherPlugin")
|
||||
|
||||
assert handle.plugin_id == "OtherPlugin"
|
||||
|
||||
|
||||
def test_clone_class_name_selects_its_own_database(plugin_data_root):
|
||||
"""分身的类名即插件运行时标识,句柄与源插件互不相同。"""
|
||||
clone_cls = type("_SamplePluginwork", (_SamplePlugin,), {})
|
||||
clone = clone_cls()
|
||||
source = _SamplePlugin()
|
||||
|
||||
clone_handle = clone.get_database()
|
||||
source_handle = source.get_database()
|
||||
|
||||
assert clone_handle.plugin_id == "_SamplePluginwork"
|
||||
assert clone_handle.db_path != source_handle.db_path
|
||||
|
||||
|
||||
def test_declared_models_reach_the_framework_through_the_lifecycle_hook(plugin_data_root):
|
||||
"""宿主一次性拉取 get_database_models() 的返回值建表,端到端验证拉取式链路。"""
|
||||
base = plugin_declarative_base()
|
||||
|
||||
class Widget(base):
|
||||
__tablename__ = "widgets"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
|
||||
class _ModelPlugin(_SamplePlugin):
|
||||
"""声明一个模型的插件测试替身。"""
|
||||
|
||||
def get_database_models(self):
|
||||
"""声明 Widget 模型。"""
|
||||
return [Widget]
|
||||
|
||||
plugin = _ModelPlugin()
|
||||
registry_module.ensure_database("_ModelPlugin", plugin.get_database_models())
|
||||
|
||||
handle = plugin.get_database()
|
||||
tables = set(sa_inspect(handle.engine).get_table_names())
|
||||
assert tables == {"widgets"}
|
||||
|
||||
|
||||
def test_sdk_database_exports_the_plugin_database_contract():
|
||||
"""SDK 门面复用 db 层的同一对象,不复制实现或制造第二套定义。"""
|
||||
import app.db.plugin.base as base_module
|
||||
import app.db.plugin.container as container_module
|
||||
import app.sdk.database as sdk_database
|
||||
|
||||
assert sdk_database.plugin_declarative_base is base_module.plugin_declarative_base
|
||||
assert sdk_database.PluginDatabaseHandle is container_module.PluginDatabaseHandle
|
||||
assert "plugin_declarative_base" in sdk_database.__all__
|
||||
assert "PluginDatabaseHandle" in sdk_database.__all__
|
||||
|
||||
|
||||
def test_host_transaction_decorators_accept_a_plugin_owned_session(plugin_data_root):
|
||||
"""db_update/db_query 的自动兜底不认识插件库,但显式传入的插件会话原样可用。"""
|
||||
base = plugin_declarative_base()
|
||||
|
||||
class Widget(base):
|
||||
__tablename__ = "widgets"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column()
|
||||
|
||||
registry_module.ensure_database("demo", (Widget,))
|
||||
handle = registry_module.get_database("demo")
|
||||
|
||||
@db_update
|
||||
def _write(db, widget):
|
||||
"""插入一条记录,提交由装饰器负责。"""
|
||||
db.add(widget)
|
||||
|
||||
@db_query
|
||||
def _count(db):
|
||||
"""读取记录数。"""
|
||||
return db.query(Widget).count()
|
||||
|
||||
session = handle.session()
|
||||
try:
|
||||
_write(session, Widget(id=1, name="a"))
|
||||
assert _count(session) == 1
|
||||
finally:
|
||||
session.close()
|
||||
Reference in New Issue
Block a user