From f27f3cb820474b7bb44c841c77679bd3ff1a33d0 Mon Sep 17 00:00:00 2001 From: Claudio Date: Mon, 11 May 2026 13:16:56 +0200 Subject: [PATCH] =?UTF-8?q?fix(handle=5Fdirectory=5Frename):=20iterate=20i?= =?UTF-8?q?n=20Python=20=E2=80=94=20asyncpg=20rejected=20SUBSTRING(...=20F?= =?UTF-8?q?ROM=20LENGTH(...)+1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The raw-SQL prefix rewrite from f4a03b6 used `SUBSTRING(filepath FROM LENGTH(:old_prefix) + 1)`. asyncpg's type inference miscategorises the LENGTH() result and rejects the parameter as "$2: int (expected str)" at execute time, so every directory-rename webhook 500'd in production despite the surrounding logic being correct. Switch to the same per-row Python loop the existing PATCH /api/v1/folders/{id} endpoint already uses. Folder renames are rare and span ≤1k photos typically — the cost of N row UPDATEs is fine. End-to-end verified: RenameTestA -> RenameTestA-FromNC (WebDAV MOVE outside mule): nc-webhook renamed (dir): {photos: 2, folders: 2, source_roots: 0} DB rows now at -FromNC ✓ -FromNC -> -ViaMule (PATCH /folders/{id} inside mule): mule rewrites synchronously webhook fires back ~30s later nc-webhook renamed (dir): {photos: 0, folders: 0, source_roots: 0} idempotent no-op against an already-updated DB ✓ --- backend/app/tasks/scan.py | 69 ++++++++++++++++++++------------------- 1 file changed, 35 insertions(+), 34 deletions(-) diff --git a/backend/app/tasks/scan.py b/backend/app/tasks/scan.py index b7a8e91..78f67b4 100644 --- a/backend/app/tasks/scan.py +++ b/backend/app/tasks/scan.py @@ -618,43 +618,44 @@ async def handle_directory_rename(old_dirpath: str, new_dirpath: str) -> dict: ) return {"status": "cross_root", "discarded": n} - # Same-root: prefix-rewrite. Use parameterised raw SQL so the - # SUBSTRING + concat happens server-side in one shot; iterating - # in Python would mean N row updates. - params = { - "new_prefix": new_prefix, - "old_prefix": old_prefix, - "off": len(old_prefix) + 1, - "old_pat": old_prefix + "/%", - } - photos_res = await session.execute( - text( - "UPDATE photos SET filepath = :new_prefix || SUBSTRING(filepath FROM :off) " - "WHERE filepath LIKE :old_pat" - ), - params, - ) - folders_res = await session.execute( - text( - "UPDATE folders SET path = CASE " - "WHEN path = :old_prefix THEN :new_prefix " - "ELSE :new_prefix || SUBSTRING(path FROM :off) END " - "WHERE path = :old_prefix OR path LIKE :old_pat" - ), - params, - ) - source_roots_res = await session.execute( - text( - "UPDATE source_roots SET path = :new_prefix WHERE path = :old_prefix" - ), - params, - ) + # Same-root: iterate the matching rows in Python and rewrite + # the prefix attribute-side. We tried a single UPDATE … SET … + # SUBSTRING(... FROM LENGTH(:old)+1) raw-SQL approach but + # asyncpg miscategorises the LENGTH() result and rejects it + # as "$2: int (expected str)". The PATCH /folders/{id} + # endpoint already loops in Python for the same reason — match + # its pattern. Folder renames are rare and typically span ≤1k + # photos, so per-row UPDATEs are fine. + old_pat = old_prefix + "/%" + photos = (await session.execute( + select(Photo).where(Photo.filepath.like(old_pat)) + )).scalars().all() + for p in photos: + p.filepath = new_prefix + p.filepath[len(old_prefix):] + + folders = (await session.execute( + select(Folder).where( + or_( + Folder.path == old_prefix, + Folder.path.like(old_pat), + ) + ) + )).scalars().all() + for f in folders: + f.path = new_prefix if f.path == old_prefix else \ + new_prefix + f.path[len(old_prefix):] + + source_roots = (await session.execute( + select(SourceRoot).where(SourceRoot.path == old_prefix) + )).scalars().all() + for sr in source_roots: + sr.path = new_prefix await session.commit() return { "status": "renamed", - "photos": photos_res.rowcount or 0, - "folders": folders_res.rowcount or 0, - "source_roots": source_roots_res.rowcount or 0, + "photos": len(photos), + "folders": len(folders), + "source_roots": len(source_roots), }