Skip to content

Commit feb742a

Browse files
authored
fix: keep card updates inside the deck and stop PUT from inserting (#97)
1 parent c64450c commit feb742a

6 files changed

Lines changed: 84 additions & 17 deletions

File tree

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ class ListDecksUseCase:
8484
### Database layer
8585

8686
- `app/models.py` — `BigIntAuditBase` from `advanced_alchemy` (auto `id`, `created_at`, `updated_at`). The module aliases `orm_registry.metadata` onto `orm.DeclarativeBase.metadata` so Alembic autogenerate sees both. New models go here.
87-
- `app/repositories.py` — Subclass `SQLAlchemyAsyncRepositoryService[Model]` with a nested `BaseRepository(SQLAlchemyAsyncRepository[Model])`. Use cases call the service methods (`get_many`, `get_one`, `create_many`) for reads and plain inserts. Writes that advanced-alchemy would spread over several round trips are one statement on the session instead: its `update` reads the row twice and refreshes it after the flush, and `upsert_many` selects the rows before and after writing them. `update_deck` sends `UPDATE ... RETURNING`, and `get_or_create` and `upsert_cards` send `INSERT ... ON CONFLICT ... RETURNING`. A statement that can hit a unique constraint runs inside `wrap_sqlalchemy_exception` so that it still raises `DuplicateKeyError`.
87+
- `app/repositories.py` — Subclass `SQLAlchemyAsyncRepositoryService[Model]` with a nested `BaseRepository(SQLAlchemyAsyncRepository[Model])`. Use cases call the service methods (`get_many`, `get_one`, `create_many`) for reads and plain inserts. Writes that advanced-alchemy would spread over several round trips are one statement on the session instead: its `update` reads the row twice and refreshes it after the flush, and `upsert_many` selects the rows before and after writing them. `update_deck` sends `UPDATE ... RETURNING` and `get_or_create` sends `INSERT ... ON CONFLICT ... RETURNING`. `update_cards` sends one `UPDATE cards ... FROM (VALUES ...) WHERE cards.id = updates.id AND cards.deck_id = :deck_id RETURNING *` and raises `NotFoundError` (404) if fewer rows come back than ids were sent, which rolls the whole request back. PUT never inserts. An `INSERT ... ON CONFLICT (id)` upsert would move a card in from another deck and would insert client-chosen ids without advancing `cards_id_seq`, so a later POST would collide on the primary key. `schemas.CardUpdates` rejects duplicate ids with a 422 before any query runs. A statement that can hit a unique constraint runs inside `wrap_sqlalchemy_exception` so that it still raises `DuplicateKeyError`.
8888
- `app/resources/db.py` — engine factories live here and are DI creators: modern-di resolves their parameter annotations at runtime, so `pyproject.toml` exempts `app/resources/` from ruff's `TC` rules. `create_session` passes `join_transaction_mode="create_savepoint"`. This is inert in production (the session binds to an engine) but enables the test rollback pattern below: when a test binds the session to a connection already in a transaction, the session owns its own savepoint so the outer transaction survives commits — do not "fix" it.
8989
- `migrations/env.py` swaps the asyncpg driver for the sync `postgresql` driver and uses `app.models.METADATA` as `target_metadata`.
9090

‎app/api/cards.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ async def create_cards(
4444
@ROUTER.put("/decks/{deck_id}/cards/")
4545
async def update_cards(
4646
deck_id: int,
47-
data: list[schemas.CardUpdate],
47+
data: schemas.CardUpdates,
4848
update_cards_use_case: UpdateCardsUseCase = FromDI(UpdateCardsUseCase),
4949
) -> schemas.Cards:
5050
return schemas.Cards.from_models(await update_cards_use_case(deck_id=deck_id, data=data))

‎app/repositories.py‎

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -72,20 +72,24 @@ async def list_for_deck(self, deck_id: int, *, after: int | None, limit: int) ->
7272
async def add_cards(self, deck_id: int, cards: list[schemas.CardCreate]) -> Sequence[models.Card]:
7373
return await self.create_many([models.Card(**card.model_dump(), deck_id=deck_id) for card in cards])
7474

75-
async def upsert_cards(self, deck_id: int, cards: list[schemas.CardUpdate]) -> Sequence[models.Card]:
75+
async def update_cards(self, deck_id: int, cards: list[schemas.CardUpdate]) -> Sequence[models.Card]:
7676
if not cards:
7777
return []
78-
statement = postgresql.insert(models.Card).values([{**card.model_dump(), "deck_id": deck_id} for card in cards])
79-
statement = statement.on_conflict_do_update(
80-
index_elements=[models.Card.id],
81-
set_={
82-
"front": statement.excluded.front,
83-
"back": statement.excluded.back,
84-
"hint": statement.excluded.hint,
85-
"deck_id": statement.excluded.deck_id,
86-
"updated_at": sa.func.now(),
87-
},
78+
updates: typing.Final = sa.values(
79+
sa.column("id", sa.BigInteger),
80+
sa.column("front", sa.String),
81+
sa.column("back", sa.String),
82+
sa.column("hint", sa.String),
83+
name="updates",
84+
).data([(card.id, card.front, card.back, card.hint) for card in cards])
85+
statement: typing.Final = (
86+
sa.update(models.Card)
87+
.where(models.Card.id == updates.c.id, models.Card.deck_id == deck_id)
88+
.values(front=updates.c.front, back=updates.c.back, hint=updates.c.hint, updated_at=sa.func.now())
89+
.returning(models.Card)
8890
)
8991
with wrap_sqlalchemy_exception(error_messages=DEFAULT_ERROR_MESSAGE_TEMPLATES, dialect_name="postgresql"):
90-
upserted: typing.Final = (await self.repository.session.scalars(statement.returning(models.Card))).all()
91-
return sorted(upserted, key=operator.attrgetter("id"))
92+
updated: typing.Final = (await self.repository.session.scalars(statement)).all()
93+
if len(updated) != len(cards):
94+
raise NotFoundError
95+
return sorted(updated, key=operator.attrgetter("id"))

‎app/schemas.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import uuid
2-
from typing import TYPE_CHECKING, Final, Protocol, Self
2+
from typing import TYPE_CHECKING, Annotated, Final, Protocol, Self
33

44
import pydantic
55
from pydantic import BaseModel, PositiveInt
@@ -55,6 +55,16 @@ class CardUpdate(CardBase):
5555
id: PositiveInt
5656

5757

58+
def _ensure_unique_ids(cards: list[CardUpdate]) -> list[CardUpdate]:
59+
if len({card.id for card in cards}) != len(cards):
60+
msg = "card ids must be unique"
61+
raise ValueError(msg)
62+
return cards
63+
64+
65+
CardUpdates = Annotated[list[CardUpdate], pydantic.AfterValidator(_ensure_unique_ids)]
66+
67+
5868
class Card(CardUpdate):
5969
deck_id: PositiveInt
6070

‎app/use_cases/update_cards.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,6 @@ class UpdateCardsUseCase:
1616
@postgres_retry
1717
async def __call__(self, *, deck_id: int, data: list[schemas.CardUpdate]) -> Sequence[models.Card]:
1818
async with self.transaction:
19-
cards: typing.Final = await self.cards_repository.upsert_cards(deck_id, data)
19+
cards: typing.Final = await self.cards_repository.update_cards(deck_id, data)
2020
await self.transaction.commit()
2121
return cards

‎tests/test_cards.py‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,59 @@ async def test_update_cards_to_a_duplicate_front(client: AsyncClient) -> None:
182182
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
183183

184184

185+
async def test_update_cards_does_not_move_a_card_from_another_deck(
186+
client: AsyncClient, db_session: AsyncSession
187+
) -> None:
188+
deck, other_deck = await factories.DeckModelFactory.create_batch_async(size=2)
189+
card = await factories.CardModelFactory.create_async(deck_id=other_deck.id)
190+
191+
response = await client.put(f"/api/decks/{deck.id}/cards/", json=[{"id": card.id, "front": "moved"}])
192+
assert response.status_code == status.HTTP_404_NOT_FOUND
193+
stored = (
194+
await db_session.execute(sa.select(models.Card.deck_id, models.Card.front).where(models.Card.id == card.id))
195+
).one()
196+
assert stored == (other_deck.id, card.front)
197+
198+
199+
async def test_update_cards_with_an_unknown_id_inserts_nothing(client: AsyncClient, db_session: AsyncSession) -> None:
200+
deck = await factories.DeckModelFactory.create_async()
201+
card = await factories.CardModelFactory.create_async(deck_id=deck.id)
202+
203+
response = await client.put(f"/api/decks/{deck.id}/cards/", json=[{"id": card.id + 1, "front": "via put"}])
204+
assert response.status_code == status.HTTP_404_NOT_FOUND
205+
assert (await db_session.scalars(sa.select(models.Card.front).where(models.Card.deck_id == deck.id))).all() == [
206+
card.front
207+
]
208+
209+
response = await client.post(f"/api/decks/{deck.id}/cards/", json=[{"front": "via post"}])
210+
assert response.status_code == status.HTTP_200_OK
211+
212+
213+
async def test_update_cards_with_one_unknown_id_updates_nothing(client: AsyncClient, db_session: AsyncSession) -> None:
214+
deck = await factories.DeckModelFactory.create_async()
215+
card = await factories.CardModelFactory.create_async(deck_id=deck.id)
216+
217+
response = await client.put(
218+
f"/api/decks/{deck.id}/cards/",
219+
json=[{"id": card.id, "front": "updated"}, {"id": card.id + 1, "front": "unknown"}],
220+
)
221+
assert response.status_code == status.HTTP_404_NOT_FOUND
222+
assert (await db_session.scalars(sa.select(models.Card.front).where(models.Card.id == card.id))).one() == card.front
223+
224+
225+
async def test_update_cards_rejects_duplicate_ids(client: AsyncClient, statements: list[str]) -> None:
226+
deck = await factories.DeckModelFactory.create_async()
227+
card = await factories.CardModelFactory.create_async(deck_id=deck.id)
228+
statements.clear()
229+
230+
response = await client.put(
231+
f"/api/decks/{deck.id}/cards/",
232+
json=[{"id": card.id, "front": "first"}, {"id": card.id, "front": "second"}],
233+
)
234+
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
235+
assert statements == []
236+
237+
185238
async def test_openapi_card_deck_id_required(client: AsyncClient) -> None:
186239
response = await client.get("/openapi.json")
187240
assert response.status_code == status.HTTP_200_OK

0 commit comments

Comments
 (0)