fix(handle_directory_rename): iterate in Python — asyncpg rejected SUBSTRING(... FROM LENGTH(...)+1)

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 ✓
This commit is contained in:
Claudio
2026-05-11 13:16:56 +02:00
parent f4a03b63f4
commit f27f3cb820

View File

@@ -618,43 +618,44 @@ async def handle_directory_rename(old_dirpath: str, new_dirpath: str) -> dict:
) )
return {"status": "cross_root", "discarded": n} return {"status": "cross_root", "discarded": n}
# Same-root: prefix-rewrite. Use parameterised raw SQL so the # Same-root: iterate the matching rows in Python and rewrite
# SUBSTRING + concat happens server-side in one shot; iterating # the prefix attribute-side. We tried a single UPDATE … SET …
# in Python would mean N row updates. # SUBSTRING(... FROM LENGTH(:old)+1) raw-SQL approach but
params = { # asyncpg miscategorises the LENGTH() result and rejects it
"new_prefix": new_prefix, # as "$2: int (expected str)". The PATCH /folders/{id}
"old_prefix": old_prefix, # endpoint already loops in Python for the same reason — match
"off": len(old_prefix) + 1, # its pattern. Folder renames are rare and typically span ≤1k
"old_pat": old_prefix + "/%", # photos, so per-row UPDATEs are fine.
} old_pat = old_prefix + "/%"
photos_res = await session.execute( photos = (await session.execute(
text( select(Photo).where(Photo.filepath.like(old_pat))
"UPDATE photos SET filepath = :new_prefix || SUBSTRING(filepath FROM :off) " )).scalars().all()
"WHERE filepath LIKE :old_pat" for p in photos:
), p.filepath = new_prefix + p.filepath[len(old_prefix):]
params,
) folders = (await session.execute(
folders_res = await session.execute( select(Folder).where(
text( or_(
"UPDATE folders SET path = CASE " Folder.path == old_prefix,
"WHEN path = :old_prefix THEN :new_prefix " Folder.path.like(old_pat),
"ELSE :new_prefix || SUBSTRING(path FROM :off) END " )
"WHERE path = :old_prefix OR path LIKE :old_pat" )
), )).scalars().all()
params, for f in folders:
) f.path = new_prefix if f.path == old_prefix else \
source_roots_res = await session.execute( new_prefix + f.path[len(old_prefix):]
text(
"UPDATE source_roots SET path = :new_prefix WHERE path = :old_prefix" source_roots = (await session.execute(
), select(SourceRoot).where(SourceRoot.path == old_prefix)
params, )).scalars().all()
) for sr in source_roots:
sr.path = new_prefix
await session.commit() await session.commit()
return { return {
"status": "renamed", "status": "renamed",
"photos": photos_res.rowcount or 0, "photos": len(photos),
"folders": folders_res.rowcount or 0, "folders": len(folders),
"source_roots": source_roots_res.rowcount or 0, "source_roots": len(source_roots),
} }