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:
Aqr-K
2026-09-01 19:36:42 -04:00
parent f4c5549a37
commit 626d8217ba
4 changed files with 253 additions and 3 deletions

View File

@@ -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):
"""
保存插件数据

View File

@@ -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",
]

View File

@@ -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*

View 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()