Merge pull request #6548 from Aqr-K/fix/plugin-schema-isolation

This commit is contained in:
jxxghp
2026-09-02 14:56:15 +08:00
committed by GitHub
5 changed files with 32 additions and 12 deletions
+2 -2
View File
@@ -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
+9 -3
View File
@@ -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}")
# 迁移目录同时描述建表与后续版本演进,与按模型建表会争夺同一批表,故优先且互斥。
+2 -2
View File
@@ -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:
+4 -3
View File
@@ -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
+15 -2
View File
@@ -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_pathschema 名带引号且回落 public。"""
"""监听器在事务开始时执行 SET LOCAL search_pathschema 名带引号且不留 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):