Skip to content

Commit 287fec7

Browse files
authored
fix: Correctly handle field implicitly generated for ForeignKey relationships (#6)
1 parent b1f3ea5 commit 287fec7

File tree

7 files changed

+497
-370
lines changed

7 files changed

+497
-370
lines changed

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,27 @@ This can be mitigated by:
153153
and [`prefetch_related`](https://docs.djangoproject.com/en/5.2/ref/models/querysets/#prefetch-related)
154154
to ensure relationships are fully loaded
155155

156+
## Foreign keys
157+
158+
When defining a `ForeignKey` field, Django will implicitly generate another field on the
159+
model with an `_id` suffix, to store the actual foreign key value. The DTO will include
160+
these implicit fields.
161+
162+
```python
163+
class Author(models.Model):
164+
name = models.CharField(max_length=100)
165+
166+
class Book(models.Model):
167+
name = models.CharField(max_length=100)
168+
author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="books")
169+
```
170+
171+
In this example, the DTO for `Book` includes the field definitions
172+
- `id: int`
173+
- `name: str`
174+
- `author_id: int`
175+
- `author: Author`
176+
156177

157178
## Serialization / validation of 3rd party field types
158179

litestar_django/dto.py

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,10 @@ def get_field_default(
174174
if isinstance(field, ForeignObjectRel):
175175
if isinstance(field, ManyToOneRel):
176176
return Empty, list
177-
return Empty, None
177+
return None, None
178+
179+
if isinstance(field, ForeignKey) and field.null:
180+
return None, None
178181

179182
if isinstance(field, ManyToManyField):
180183
return Empty, list
@@ -189,6 +192,35 @@ def get_field_default(
189192

190193
return default, default_factory
191194

195+
@classmethod
196+
def get_model_fields(
197+
cls, model_type: type[T]
198+
) -> Generator[tuple[str, AnyField], None, None]:
199+
for field in model_type._meta.get_fields():
200+
yield field.name, field
201+
if isinstance(field, ForeignKey):
202+
# if it's a fk, also include the implicitly generated '_id' fields
203+
# generated by django
204+
for fk_tuple in field.related_fields:
205+
# 'attname' is the name of the '_id' field on the referring model
206+
name = fk_tuple[0].attname
207+
# there is no concrete, distinct field for the '_id' attribute;
208+
# it's the same as the 'ForeignKey' field on the type. on the
209+
# concrete class, these fields are 'ForwardManyToOneDescriptor' for
210+
# the explicitly defined 'ForeignKey' field, and a
211+
# 'ForeignKeyDeferredAttribute' for the implicitly created '_id'
212+
# field.
213+
# we need a concrete field to infer the type though, so we construct
214+
# it from the type of the related primary key field
215+
related_field = fk_tuple[1]
216+
id_field = type(related_field)(
217+
name=name,
218+
null=field.null,
219+
validators=related_field.validators,
220+
default=None if field.null else NOT_PROVIDED,
221+
)
222+
yield name, id_field
223+
192224
@classmethod
193225
def generate_field_definitions(
194226
cls, model_type: Type[T]
@@ -198,9 +230,7 @@ def generate_field_definitions(
198230
field_type_map = {**_FIELD_TYPE_MAP, **cls.custom_field_types}
199231

200232
field: AnyField
201-
for field in model_type._meta.get_fields():
202-
name = field.name
203-
233+
for name, field in cls.get_model_fields(model_type):
204234
if field.hidden:
205235
dto_field = DTOField("private")
206236
elif not field.editable:
@@ -210,6 +240,9 @@ def generate_field_definitions(
210240

211241
if field.is_relation and field.related_model:
212242
related = field.related_model
243+
# all relationships are 'read-only', because Django does not support
244+
# inline creation of related objects
245+
dto_field = DTOField("read-only")
213246
if isinstance(field, (ForeignKey, OneToOneField)):
214247
field_type: Any = related
215248
elif isinstance(field, ManyToManyField) or getattr(

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "litestar-django"
3-
version = "0.2.0"
3+
version = "0.2.1"
44
description = "Django model support for Litestar"
55
readme = "README.md"
66
license = { text = "MIT" }

tests/some_app/app/models.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,17 @@ class Genre(models.Model):
144144
name = models.CharField(max_length=50)
145145

146146

147+
class Tag(models.Model):
148+
name = models.CharField(max_length=100)
149+
150+
147151
class Book(models.Model):
148152
name = models.CharField(max_length=100)
149153
author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="books")
154+
nullable_tag = models.ForeignKey(
155+
Tag,
156+
on_delete=models.CASCADE,
157+
null=True,
158+
related_name="books",
159+
)
150160
genres = models.ManyToManyField(Genre, related_name="books")

tests/test_dto_field_definitions.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
StdEnum,
2222
LabelledEnum,
2323
ModelInvalidRegexValidator,
24+
Tag,
2425
)
2526

2627
# django extract these from SQLite on 5.1 and above
@@ -532,6 +533,56 @@ def test_relationship_to_one() -> None:
532533
name="author",
533534
kwarg_definition=KwargDefinition(title="author"),
534535
),
536+
dto_field=DTOField("read-only"),
537+
)
538+
539+
assert field_defs["author_id"] == DTOFieldDefinition.from_field_definition(
540+
model_name="Book",
541+
default_factory=None,
542+
field_definition=FieldDefinition.from_annotation(
543+
int,
544+
name="author_id",
545+
kwarg_definition=KwargDefinition(
546+
title="author_id",
547+
# min/max int values set by django
548+
lt=MAX_INT_VALUE,
549+
gt=MIN_INT_VALUE,
550+
),
551+
),
552+
dto_field=DTOField(),
553+
)
554+
555+
556+
def test_relationship_to_one_nullable() -> None:
557+
dto_type = DjangoModelDTO[Book]
558+
field_defs = {f.name: f for f in dto_type.generate_field_definitions(Book)}
559+
560+
assert field_defs["nullable_tag"] == DTOFieldDefinition.from_field_definition(
561+
model_name="Book",
562+
default_factory=None,
563+
field_definition=FieldDefinition.from_annotation(
564+
Optional[Tag],
565+
name="nullable_tag",
566+
default=None,
567+
kwarg_definition=KwargDefinition(title="nullable tag"),
568+
),
569+
dto_field=DTOField("read-only"),
570+
)
571+
572+
assert field_defs["nullable_tag_id"] == DTOFieldDefinition.from_field_definition(
573+
model_name="Book",
574+
default_factory=None,
575+
field_definition=FieldDefinition.from_annotation(
576+
Optional[int],
577+
name="nullable_tag_id",
578+
default=None,
579+
kwarg_definition=KwargDefinition(
580+
title="nullable_tag_id",
581+
# no limit values if the field is nullable
582+
lt=None,
583+
gt=None,
584+
),
585+
),
535586
dto_field=DTOField(),
536587
)
537588

@@ -566,7 +617,7 @@ def test_relationship_to_many() -> None:
566617
name="genres",
567618
kwarg_definition=KwargDefinition(title="genres"),
568619
),
569-
dto_field=DTOField(),
620+
dto_field=DTOField("read-only"),
570621
)
571622

572623

tests/test_dto_integration.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,8 @@ def test_serialize_to_many() -> None:
110110
def handler() -> list[Book]:
111111
# need to prefetch here, so we don't accidentally perform lazy-loading during
112112
# the transfer process
113-
return list(Book.objects.prefetch_related("author", "genres").all())
113+
data = list(Book.objects.prefetch_related("author", "genres").all())
114+
return data
114115

115116
with create_test_client(
116117
[handler], raise_server_exceptions=True, debug=True
@@ -121,13 +122,19 @@ def handler() -> list[Book]:
121122
{
122123
"id": book_a.id,
123124
"name": "book_a",
125+
"author_id": author.id,
124126
"author": {"id": author.id, "name": "Someone"},
127+
"nullable_tag_id": None,
128+
"nullable_tag": None,
125129
"genres": [{"id": genre_a.id, "name": "genre_a"}],
126130
},
127131
{
128132
"id": book_b.id,
129133
"name": "book_b",
134+
"author_id": author.id,
130135
"author": {"id": author.id, "name": "Someone"},
136+
"nullable_tag_id": None,
137+
"nullable_tag": None,
131138
"genres": [
132139
{"id": genre_a.id, "name": "genre_a"},
133140
{"id": genre_b.id, "name": "genre_b"},
@@ -136,7 +143,10 @@ def handler() -> list[Book]:
136143
{
137144
"id": book_c.id,
138145
"name": "book_c",
146+
"author_id": author.id,
139147
"author": {"id": author.id, "name": "Someone"},
148+
"nullable_tag_id": None,
149+
"nullable_tag": None,
140150
"genres": [],
141151
},
142152
]
@@ -147,8 +157,10 @@ def test_validate() -> None:
147157
@post(
148158
"/",
149159
sync_to_thread=True,
150-
dto=DjangoModelDTO[Annotated[Author, DTOConfig(exclude={"id", "books"})]],
151-
return_dto=DjangoModelDTO[Author],
160+
dto=DjangoModelDTO[Annotated[Author, DTOConfig(include={"name"})]],
161+
return_dto=DjangoModelDTO[
162+
Annotated[Author, DTOConfig(include={"id", "name", "books"})]
163+
],
152164
)
153165
def handler(data: Author) -> Author:
154166
data.save()

0 commit comments

Comments
 (0)