mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-09 09:26:55 +08:00
fix(plugin): 插件 schema 不再回落 public,迁移目录拒绝相对路径
PostgreSQL 下 search_path 留了 public 兜底,插件 schema 里不存在的表名会继续 解析到宿主同名表,一条未限定的 DELETE 就能改到宿主数据;解析根收敛为插件自己的 schema,找不到的表名直接报错。 get_database_migrations() 的合同要求绝对路径,此前只校验目录是否存在,相对路径 会按宿主进程的当前工作目录解析成另一条迁移链,改为直接拒绝。
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
# 迁移目录同时描述建表与后续版本演进,与按模型建表会争夺同一批表,故优先且互斥。
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user