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}
# 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),
}