fix: reuse existing link rows when updating book relationships

Assigning through the association proxy recreated links for targets the book
already had, colliding with the link tables' unique constraints. Reconcile the
collections in place instead, identifiers included.
This commit is contained in:
2026-08-12 15:17:48 -04:00
parent a1f39a8dc8
commit 540522e828
2 changed files with 160 additions and 3 deletions
@@ -67,3 +67,74 @@ class TestBookServiceCRUD:
assert updated_book.publisher.name == "Tolkien Estate"
assert len(updated_book.tags) == 2
async def test_update_book_reuses_existing_links(
self, books_service: BookService, test_library: m.Library
) -> None:
"""Re-submitting a relationship a book already has must not duplicate its link.
The link tables are unique on (book_id, tag_id) / (book_id, author_id), and
identifiers on (name, book_id), so replacing a collection wholesale used to
insert a row that collided with the one it was replacing.
"""
book_data = BookCreate(
library_id=1,
title="The Two Towers",
authors=["J.R.R Tolkien"],
tags=["Fantasy"],
identifiers={"isbn-13": "9780261102358"},
pages=352,
)
book = await books_service.to_model_on_create(book_data.model_dump())
assert isinstance(book, m.Book)
# Matches what the default template generates, so these updates move nothing.
book.path = f"{test_library.root_path}/J.R.R Tolkien/The Two Towers"
await aios.makedirs(book.path) # type: ignore[arg-type]
books_service.repository.session.add(book)
await books_service.repository.session.commit()
await books_service.repository.session.refresh(book)
# Through the service, so the link collections come back eagerly loaded.
created_book = await books_service.get(book.id)
original_tag_link_id = created_book.tag_links[0].id
# Every collection resubmitted unchanged.
await books_service.update_book(
book.id,
{
"authors": ["J.R.R Tolkien"],
"tags": ["Fantasy"],
"identifiers": {"isbn-13": "9780261102358"},
},
test_library,
)
updated_book = await books_service.get(book.id)
assert [tag.name for tag in updated_book.tags] == ["Fantasy"]
assert [author.name for author in updated_book.authors] == ["J.R.R Tolkien"]
assert len(updated_book.identifiers) == 1
# The existing link is reused, not deleted and reinserted.
assert updated_book.tag_links[0].id == original_tag_link_id
# Keeping one tag while adding another.
await books_service.update_book(
book.id, {"tags": ["Fantasy", "Adventure"]}, test_library
)
updated_book = await books_service.get(book.id)
assert [tag.name for tag in updated_book.tags] == ["Fantasy", "Adventure"]
# Dropping one while keeping the other.
await books_service.update_book(book.id, {"tags": ["Adventure"]}, test_library)
updated_book = await books_service.get(book.id)
assert [tag.name for tag in updated_book.tags] == ["Adventure"]
# A name the book already carries has its value updated in place.
await books_service.update_book(
book.id, {"identifiers": {"isbn-13": "9780261102999"}}, test_library
)
updated_book = await books_service.get(book.id)
assert len(updated_book.identifiers) == 1
assert updated_book.identifiers[0].value == "9780261102999"