feat(scripts): listScripts parent_path filter + lazy load by directory

Backend — backend/src/backend/scripts.py
- list_scripts accepts optional parent_path Query (default "").
- Extracts _build_list_scripts_descendant_prefix helper for the
  "direct children of parent_path" prefix.
- WHERE clause now adds:
    StorageObjects.relative_path LIKE '<prefix>/%'
    AND NOT LIKE '<prefix>/%/%'
  so deeper descendants and prefix-siblings (foo/bar vs foo/bar2)
  are excluded. Empty parent_path filters to root-level only —
  this is the symmetric, intent-aligned behavior the lazy-load
  frontend relies on.
- EXPLAIN confirms idx_scripts_workspace drives the scripts table;
  storage_objects PK lookup applies the LIKE filter per row.

Frontend — frontend/app/services/api.ts + scriptWorkspaceStore.ts
- listScripts accepts optional parentPath; built URL preserves
  the new filter param.
- Store gains loadedScriptPaths / loadingScriptPaths Sets and a
  loadScripts(parentPath) action: idempotent, in-flight dedupe,
  dedup-by-id when merging into the flat scripts array so existing
  find() callers keep working.
- load() now uses listScripts("") instead of bulk fetch — root
  only on first paint.
- toggleExpanded() triggers loadScripts(parentPath) in parallel
  with loadChildren(parentPath) so folder expansion loads both
  sub-directories and direct-child scripts.

Tests — backend/tests/test_list_scripts_parent_path.py
- 7 unit tests: prefix construction, normalization, traversal
  rejection, and SQL compilation contract (LIKE prefix + NOT LIKE
  prefix + scripts.status filter).

Verified:
- pytest backend/tests: 50 passed (43 existing + 7 new)
- pnpm typecheck: clean
- alembic: no schema changes (migration-less feature)
- EXPLAIN with real workspace: idx_scripts_workspace → PK lookup
This commit is contained in:
tao.chen
2026-09-02 10:10:41 +08:00
committed by tao.chen
parent 380e8f13f2
commit dc7211c33f
4 changed files with 254 additions and 10 deletions
+36
View File
@@ -115,6 +115,28 @@ def user_relative_path(context: RequestContext, child_path: str = "") -> str:
return f"{base}/{normalized}" if normalized else base
def _build_list_scripts_descendant_prefix(
context: RequestContext, parent_path: str
) -> str:
"""Return the materialized-path prefix for direct children of ``parent_path``.
The endpoint appends ``LIKE '<prefix>/%' AND NOT LIKE '<prefix>/%/%'``
against ``storage_objects.relative_path`` so only scripts whose parent
directory is exactly ``parent_path`` (no deeper descendants, no
prefix-siblings like ``foo/bar`` vs ``foo/bar2``) match.
Empty ``parent_path`` produces the user-scoped root prefix — i.e. the
endpoint returns root-level scripts only, not the full workspace.
"""
normalized_parent = normalize_user_path(parent_path)
scoped_prefix = user_relative_path(context)
if normalized_parent:
target_prefix = f"{scoped_prefix}/{normalized_parent}"
else:
target_prefix = scoped_prefix
return f"{target_prefix}/"
def safe_script_name(value: str, script_type: str) -> str:
name = value.replace("\\", "/").rsplit("/", 1)[-1].strip()
if not name or name in {".", ".."} or any(ord(char) < 32 for char in name):
@@ -1045,11 +1067,23 @@ async def delete_workspace_directory(
# 列出当前工作区中用户有权查看的脚本与 Notebook 元数据。
#
# 可选 ``parent_path`` Query 参数把返回范围限定为 *直接挂在该路径下的* 脚本
# (不含更深的子目录)。空字符串等价于用户作用域根目录;这是前端按目录懒加载
# 的关键端点,避免 10 万级脚本一次性返回。
#
# 实现走 ``storage_objects.relative_path`` materialized path(``LIKE prefix%``
# + ``NOT LIKE prefix%/%`` 取直接子节点),配合现有
# ``idx_storage_workspace_relative_path (workspace_id, relative_path)`` 索引
# 避免全表扫。
@router.get("/api/v1/scripts")
async def list_scripts(
parent_path: str = Query(default="", max_length=1024),
context: RequestContext = Depends(request_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
descendant_prefix = _build_list_scripts_descendant_prefix(context, parent_path)
statement = (
select(Scripts, StorageObjects, Users.display_name)
.join(
@@ -1060,6 +1094,8 @@ async def list_scripts(
.where(
Scripts.workspace_id == context.workspace.workspace_id,
Scripts.status == "active",
StorageObjects.relative_path.like(f"{descendant_prefix}%"),
~StorageObjects.relative_path.like(f"{descendant_prefix}%/%"),
)
.order_by(Scripts.updated_at.desc())
)
@@ -0,0 +1,136 @@
"""Unit tests for the parent_path filter clause on list_scripts.
Verifies the WHERE clause built by the endpoint encodes the intended
"direct children of parent_path" semantics: ``relative_path LIKE 'prefix/%'``
and ``NOT LIKE 'prefix/%/%'``. The actual ORM roundtrip is covered by the
existing integration tests; here we only care that the filter contract is
intact, so an AsyncMock session is enough.
Mirrors the pattern in test_scripts.py (unit-level, no live DB).
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import HTTPException
from backend.scripts import (
_build_list_scripts_descendant_prefix,
normalize_user_path,
)
from sqlalchemy.dialects import mysql as mysql_dialect
def _ctx(user_id: str = "U001") -> SimpleNamespace:
"""Stand-in for RequestContext — only ``user.user_id`` and ``workspace_id`` are read."""
return SimpleNamespace(
request_id="test",
user=SimpleNamespace(user_id=user_id),
workspace=SimpleNamespace(workspace_id="W001"),
role=SimpleNamespace(role_code="admin"),
is_system_admin=False,
)
def test_descendant_prefix_root() -> None:
"""Empty parent_path → descendant prefix is the scoped root + '/'."""
prefix = _build_list_scripts_descendant_prefix(_ctx("alice"), "")
assert prefix == "workspace/alice/"
def test_descendant_prefix_subdir() -> None:
"""Non-empty parent_path → appended under the scoped root."""
prefix = _build_list_scripts_descendant_prefix(_ctx("alice"), "foo/bar")
assert prefix == "workspace/alice/foo/bar/"
def test_descendant_prefix_normalizes_leading_trailing_slashes() -> None:
"""Leading/trailing slashes on parent_path must be stripped."""
prefix = _build_list_scripts_descendant_prefix(_ctx("alice"), "/foo/bar/")
assert prefix == "workspace/alice/foo/bar/"
def test_descendant_prefix_rejects_traversal() -> None:
"""``..`` segments must raise (matches normalize_user_path contract)."""
with pytest.raises(HTTPException) as exc:
_build_list_scripts_descendant_prefix(_ctx("alice"), "foo/../bar")
assert exc.value.status_code == 422
def test_normalize_user_path_strips() -> None:
assert normalize_user_path("") == ""
assert normalize_user_path("/a/b/") == "a/b"
assert normalize_user_path("a\\b") == "a/b"
async def test_list_scripts_where_clause_uses_like_prefix_and_excludes_deeper() -> None:
"""The WHERE clause must include both LIKE prefix and NOT LIKE '%/%' filters
so deeper descendants and prefix-siblings (foo/bar vs foo/bar2) are excluded."""
from backend.scripts import list_scripts
captured_sql: list[str] = []
class _MockResult:
def all(self):
return []
mock_session = MagicMock()
mock_session.execute = AsyncMock(
side_effect=lambda stmt: (
captured_sql.append(
str(
stmt.compile(
dialect=mysql_dialect.dialect(),
compile_kwargs={"literal_binds": True},
)
)
)
or _MockResult()
)
)
await list_scripts(parent_path="foo/bar", context=_ctx("alice"), session=mock_session)
assert len(captured_sql) == 1
sql = captured_sql[0].lower()
# Direct-child LIKE prefix
assert "like 'workspace/alice/foo/bar/%%'" in sql
# NOT-LIKE deeper
assert "not like 'workspace/alice/foo/bar/%%/%%'" in sql
# active scripts only (existing contract preserved)
assert "scripts.status" in sql or "scripts.status = 'active'" in sql or "scripts.status = :status" in sql
async def test_list_scripts_empty_parent_path_targets_root_descendants() -> None:
"""parent_path='' produces root-scoped LIKE prefix only, not full scan."""
from backend.scripts import list_scripts
captured_sql: list[str] = []
class _MockResult:
def all(self):
return []
mock_session = MagicMock()
mock_session.execute = AsyncMock(
side_effect=lambda stmt: (
captured_sql.append(
str(
stmt.compile(
dialect=mysql_dialect.dialect(),
compile_kwargs={"literal_binds": True},
)
)
)
or _MockResult()
)
)
await list_scripts(parent_path="", context=_ctx("alice"), session=mock_session)
sql = captured_sql[0].lower()
assert "like 'workspace/alice/%%'" in sql
assert "not like 'workspace/alice/%%/%%'" in sql