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:
@@ -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())
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user