diff --git a/app/plugins/__init__.py b/app/plugins/__init__.py index 2d00c6fc1..8bc7d4efb 100644 --- a/app/plugins/__init__.py +++ b/app/plugins/__init__.py @@ -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): """ 保存插件数据 diff --git a/app/sdk/database.py b/app/sdk/database.py index a74da2f78..0cee48f42 100644 --- a/app/sdk/database.py +++ b/app/sdk/database.py @@ -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", ] diff --git a/docs/rules/10-data-and-persistent.md b/docs/rules/10-data-and-persistent.md index ca98266be..5299a7d9d 100644 --- a/docs/rules/10-data-and-persistent.md +++ b/docs/rules/10-data-and-persistent.md @@ -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//plugin.db`; PostgreSQL deploys one schema +`plugin_` 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* diff --git a/tests/test_plugin_database_hooks.py b/tests/test_plugin_database_hooks.py new file mode 100644 index 000000000..eb40ab606 --- /dev/null +++ b/tests/test_plugin_database_hooks.py @@ -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()