Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/relations/foreign-key.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,26 @@ Finally you can explicitly set it to None (default behavior if no value passed).
--8<-- "../docs_src/relations/docs001.py"
```

You can also clear a `ForeignKey` relation on an already-loaded model by
assigning `None` — the field must be `nullable` for this to persist. The
related model is unregistered from the instance immediately, so subsequent
reads return `None` without requiring a reload:

```python
track = await Track.objects.select_related("album").get(id=track_id)
assert track.album is not None

track.album = None
assert track.album is None # in-memory state cleared

await track.update(_columns=["album"]) # persists NULL to the database
```

!!!note
Assigning `None` to a non-nullable `ForeignKey` is ignored at the
in-memory level. Make the field `nullable=True` (the default) if you
need to unassign it.

!!!warning
In all not None cases the primary key value for related model **has to exist in database**.

Expand Down
18 changes: 14 additions & 4 deletions ormar/models/descriptors/descriptors.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import base64
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Optional, cast

from ormar.fields.parsers import decode_bytes, encode_json

Expand Down Expand Up @@ -106,9 +106,19 @@ def __get__(self, instance: "Model", owner: type["Model"]) -> Any:
return None # pragma no cover

def __set__(self, instance: "Model", value: Any) -> None:
instance.ormar_config.model_fields[self.name].expand_relationship(
value=value, child=instance
)
field = instance.ormar_config.model_fields[self.name]
field.expand_relationship(value=value, child=instance)

if (
value is None
and field.nullable
and not field.virtual
and not field.is_multi
and self.name in instance._orm
):
previous = cast(Optional["Model"], instance._orm.get(self.name))
if previous is not None:
instance._orm.remove(self.name, previous)

if not isinstance(instance.__dict__.get(self.name), list):
instance.set_save_status(False)
18 changes: 18 additions & 0 deletions tests/test_relations/test_foreign_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -406,3 +406,21 @@ async def test_bulk_update_model_with_children():
is_best_seller=True
).all()
assert len(best_seller_albums_db) == 2


@pytest.mark.asyncio
async def test_fk_update_to_none():
async with base_ormar_config.database:
async with base_ormar_config.database.transaction(force_rollback=True):
album = await Album.objects.create(name="Limitless")
track = await Track.objects.create(album=album, title="Test1", position=1)
assert track.album.name == "Limitless"

track.album = None
assert track.album is None

updated_track = await track.update(_columns=["album"])
assert updated_track.album is None

reloaded = await Track.objects.get(id=track.id)
assert reloaded.album is None
Loading