From 862ed4d5b83860f0a6f36a3ee020805cc992378a Mon Sep 17 00:00:00 2001 From: Aqr-K <1210498076@qq.com> Date: Wed, 2 Sep 2026 02:30:15 -0400 Subject: [PATCH] =?UTF-8?q?fix(plugin):=20=E6=8F=92=E4=BB=B6=20schema=20?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=9B=9E=E8=90=BD=20public=EF=BC=8C=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E7=9B=AE=E5=BD=95=E6=8B=92=E7=BB=9D=E7=9B=B8=E5=AF=B9?= =?UTF-8?q?=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PostgreSQL 下 search_path 留了 public 兜底,插件 schema 里不存在的表名会继续 解析到宿主同名表,一条未限定的 DELETE 就能改到宿主数据;解析根收敛为插件自己的 schema,找不到的表名直接报错。 get_database_migrations() 的合同要求绝对路径,此前只校验目录是否存在,相对路径 会按宿主进程的当前工作目录解析成另一条迁移链,改为直接拒绝。 --- app/db/plugin/container.py | 4 ++-- app/db/plugin/registry.py | 12 +++++++++--- app/plugins/__init__.py | 4 ++-- docs/rules/10-data-and-persistent.md | 7 ++++--- tests/test_db_plugin_framework.py | 17 +++++++++++++++-- 5 files changed, 32 insertions(+), 12 deletions(-) diff --git a/app/db/plugin/container.py b/app/db/plugin/container.py index 9679d1f3d..7c0c2c774 100644 --- a/app/db/plugin/container.py +++ b/app/db/plugin/container.py @@ -19,8 +19,8 @@ class PluginDatabaseHandle: SQLite 下引擎由本句柄独占,``owns_engine`` 为真;PostgreSQL 下引擎是宿主引擎按 ``schema_translate_map`` 派生的外观,``owns_engine`` 为假——只有前者可以 dispose, 后者一旦 dispose 会连累宿主与其它插件仍在使用的同一个连接池。PostgreSQL 下本句柄的 - 会话与连接在每个事务开始时把 ``search_path`` 限定到插件 schema,未限定的原生 SQL 因此 - 同样解析到插件自己的表。 + 会话与连接在每个事务开始时把 ``search_path`` 限定到插件 schema 本身,未限定的原生 SQL + 因此同样解析到插件自己的表,找不到的表名直接报错而不会落到宿主同名表。 """ plugin_id: str diff --git a/app/db/plugin/registry.py b/app/db/plugin/registry.py index 40d235542..73a9e3ef7 100644 --- a/app/db/plugin/registry.py +++ b/app/db/plugin/registry.py @@ -48,15 +48,16 @@ def _search_path_setter(schema: str) -> Callable[[Connection], None]: ``schema_translate_map`` 只改写 SQLAlchemy 生成的 schema 感知语句,``text()`` 一类 的原生 SQL 不在其列;不动 ``search_path``,插件按合同写的未限定原生 SQL 会落到 - ``public``。``SET LOCAL`` 的作用域随事务结束,不会经连接池把插件的解析根泄漏给宿主 - 或其它插件。 + ``public``。解析根只放插件自己的 schema,不留 ``public`` 兜底:留了兜底,插件 schema + 里不存在的表名会继续解析到宿主同名表,一条未限定的 ``DELETE`` 就能改到宿主数据。 + ``SET LOCAL`` 的作用域随事务结束,不会经连接池把插件的解析根泄漏给宿主或其它插件。 :param schema: 插件 schema 名 :return: 绑定该 schema 的 ``begin`` 事件监听器 """ def _apply_search_path(connection: Connection) -> None: """在事务开始时把该连接的未限定标识符解析根切到插件 schema。""" - connection.exec_driver_sql(f'SET LOCAL search_path TO "{schema}", public') + connection.exec_driver_sql(f'SET LOCAL search_path TO "{schema}"') return _apply_search_path @@ -169,11 +170,16 @@ def ensure_database( :param plugin_id: 插件标识 :param models: 插件声明的模型类 :param migrations: 插件声明的 Alembic 迁移目录 + :raise ValueError: 声明的迁移目录不是绝对路径 :raise FileNotFoundError: 声明的迁移目录不存在 """ if migrations is not None: # 目录校验必须早于建句柄:alembic 找不到 script_location 时抛错,而句柄已经把 # 库文件建了出来,插件下次启动面对的是一个既没有表、也没有版本号的空库 + # 相对路径按宿主进程的当前工作目录解析,插件预期的脚本与实际执行的脚本可能是 + # 两条不同的迁移链,宁可拒绝也不能让它建出错误的表结构与版本记录 + if not migrations.is_absolute(): + raise ValueError(f"插件 {plugin_id} 声明的迁移目录不是绝对路径:{migrations}") if not migrations.is_dir(): raise FileNotFoundError(f"插件 {plugin_id} 声明的迁移目录不存在:{migrations}") # 迁移目录同时描述建表与后续版本演进,与按模型建表会争夺同一批表,故优先且互斥。 diff --git a/app/plugins/__init__.py b/app/plugins/__init__.py index ee9dfdd2a..0d93bf164 100644 --- a/app/plugins/__init__.py +++ b/app/plugins/__init__.py @@ -255,7 +255,7 @@ class _PluginBase(metaclass=ABCMeta): def get_database_migrations(self) -> Optional[Union[str, Path]]: """ 声明插件自有数据库的 Alembic 迁移脚本目录 - 目录须符合 Alembic script_location 布局,且必须是绝对路径 + 目录须符合 Alembic script_location 布局,且必须是绝对路径,否则插件启动时报错 (相对路径按宿主进程的当前工作目录解析,插件无法预期它指向哪里, 建议用 Path(__file__).parent / "migrations" 之类的写法取得) @@ -307,7 +307,7 @@ class _PluginBase(metaclass=ABCMeta): 获取插件自有数据库句柄,用于取会话读写插件自有表 句柄不存在时按需建立,不要求先声明模型 PostgreSQL 下句柄的会话与连接在每个事务开始时把 search_path 限定到本插件 schema, - 未限定的原生 SQL 因此同样解析到插件自己的表,不会落到 public + 未限定的原生 SQL 因此同样解析到插件自己的表,找不到的表名直接报错而非落到宿主表 :param plugin_id: 插件ID """ if not plugin_id: diff --git a/docs/rules/10-data-and-persistent.md b/docs/rules/10-data-and-persistent.md index 34016f8ae..ceb4a077a 100644 --- a/docs/rules/10-data-and-persistent.md +++ b/docs/rules/10-data-and-persistent.md @@ -274,9 +274,10 @@ same as never having a database: `get_database()` creates the SQLite file (or the PostgreSQL schema) on first call, so a plugin that only runs raw SQL still gets an isolated database. Under PostgreSQL the handle's sessions and connections issue `SET LOCAL search_path` at the start of every transaction, -so unqualified raw SQL resolves to the plugin's own schema rather than -`public`; `SET LOCAL` ends with the transaction and never leaks back to the -host through the shared pool. +naming the plugin schema alone. Unqualified raw SQL therefore resolves inside +the plugin's own schema, and a name missing there fails instead of falling +through to a host table of the same name; `SET LOCAL` ends with the +transaction and never leaks back to the host through the shared pool. Lifecycle is strictly ensure/release/destroy: plugin start calls `ensure` after `init_plugin()`; stop, reload and remove call `release` only, which diff --git a/tests/test_db_plugin_framework.py b/tests/test_db_plugin_framework.py index fd2ce9e50..879644d1d 100644 --- a/tests/test_db_plugin_framework.py +++ b/tests/test_db_plugin_framework.py @@ -512,6 +512,18 @@ def test_release_closes_the_thread_local_session(plugin_data_root, sqlite_backen assert handle.scoped_session_factory.registry.has() is False +def test_relative_migrations_directory_is_rejected_before_any_file_is_created( + plugin_data_root, + sqlite_backend, +): + """迁移目录是相对路径时直接抛错:它按宿主工作目录解析,可能是另一条迁移链。""" + with pytest.raises(ValueError): + registry_module.ensure_database("demo", (), Path("migrations")) + + assert "demo" not in registry_module._handles + assert not (plugin_data_root / "demo").exists() + + def test_missing_migrations_directory_is_rejected_before_any_file_is_created( plugin_data_root, sqlite_backend, @@ -694,13 +706,14 @@ def test_destroy_blocks_a_concurrent_handle_rebuild_until_the_carrier_is_removed def test_search_path_setter_binds_the_quoted_plugin_schema(): - """监听器在事务开始时执行 SET LOCAL search_path,schema 名带引号且回落 public。""" + """监听器在事务开始时执行 SET LOCAL search_path,schema 名带引号且不留 public 兜底。""" connection = MagicMock(name="connection") registry_module._search_path_setter("plugin_demo")(connection) executed = connection.exec_driver_sql.call_args.args[0] - assert executed == 'SET LOCAL search_path TO "plugin_demo", public' + assert executed == 'SET LOCAL search_path TO "plugin_demo"' + assert "public" not in executed def test_begin_listener_on_a_derived_engine_never_reaches_the_host_engine(tmp_path):