Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use property to alias ForeignKey fields instead of Pydantic alias #1111

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
40 changes: 22 additions & 18 deletions docs/docs/guides/response/config-pydantic.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,16 @@ There are many customizations available for a **Django Ninja `Schema`**, via the
when using Django models, as Pydantic's model class is called Model by default, and conflicts with
Django's Model class.

## Example Camel Case mode
## Automatic Camel Case Aliases

One interesting `Config` attribute is [`alias_generator`](https://pydantic-docs.helpmanual.io/usage/model_config/#alias-generator).
One useful `Config` attribute is [`alias_generator`](https://pydantic-docs.helpmanual.io/usage/model_config/#alias-generator).
We can use it to automatically generate aliases for field names with a given function. This is mostly commonly used to create
an API that uses camelCase for its property names.
Using Pydantic's example in **Django Ninja** can look something like:

```python hl_lines="12 13"
```python hl_lines="9 10"
from ninja import Schema


def to_camel(string: str) -> str:
return ''.join(word.capitalize() for word in string.split('_'))
from pydantic.alias_generators import to_camel


class CamelModelSchema(Schema):
Expand All @@ -31,17 +30,20 @@ class CamelModelSchema(Schema):
```

!!! note
When overriding the schema's `Config`, it is necessary to inherit from the base `Config` class.
When overriding the schema's `Config`, it is necessary to inherit from the base `Schema.Config` class.

Keep in mind that when you want modify output for field names (like cammel case) - you need to set as well `populate_by_name` and `by_alias`
To alias `ModelSchema`'s field names, you'll also need to set `populate_by_name` on the `Schema` config and
enable `by_alias` in all endpoints using the model.

```python hl_lines="6 9"
```python hl_lines="4 11"
class UserSchema(ModelSchema):
class Config:
model = User
model_fields = ["id", "email"]
class Config(Schema.Config):
alias_generator = to_camel
populate_by_name = True # !!!!!! <--------

class Meta:
model = User
model_fields = ["id", "email", "created_date"]


@api.get("/users", response=list[UserSchema], by_alias=True) # !!!!!! <-------- by_alias
Expand All @@ -55,13 +57,15 @@ results:
```JSON
[
{
"Id": 1,
"Email": "[email protected]"
"id": 1,
"email": "[email protected]",
"createdDate": "2011-08-24"
},
{
"Id": 2,
"Email": "[email protected]"
}
"id": 2,
"email": "[email protected]",
"createdDate": "2012-03-06"
},
...
]

Expand Down
14 changes: 11 additions & 3 deletions ninja/orm/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from pydantic import create_model as create_pydantic_model

from ninja.errors import ConfigError
from ninja.orm.fields import get_schema_field
from ninja.orm.fields import get_field_property_accessors, get_schema_field
from ninja.schema import Schema

# MAYBE:
Expand Down Expand Up @@ -62,12 +62,13 @@ def create_schema(

definitions = {}
for fld in model_fields_list:
python_type, field_info = get_schema_field(
# types: ignore
field_name, python_type, field_info = get_schema_field(
fld,
depth=depth,
optional=optional_fields and (fld.name in optional_fields),
)
definitions[fld.name] = (python_type, field_info)
definitions[field_name] = (python_type, field_info)

if custom_fields:
for fld_name, python_type, field_info in custom_fields:
Expand Down Expand Up @@ -96,6 +97,13 @@ def create_schema(
# **field_definitions: Any,
self.schemas[key] = schema
self.schema_names.add(name)

if depth == 0:
for fld in model_fields_list:
if fld.is_relation:
field_name, prop = get_field_property_accessors(fld)
setattr(schema, field_name, prop)

return schema

def get_key(
Expand Down
43 changes: 37 additions & 6 deletions ninja/orm/fields.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
import datetime
from decimal import Decimal
from typing import Any, Callable, Dict, List, Tuple, Type, TypeVar, Union, no_type_check
from typing import (
Any,
Callable,
Dict,
List,
Tuple,
Type,
TypeVar,
Union,
cast,
no_type_check,
)
from uuid import UUID

from django.db.models import ManyToManyField
from django.db.models.fields import Field as DjangoField
from pydantic import IPvAnyAddress
from pydantic import BaseModel, IPvAnyAddress
from pydantic.fields import FieldInfo
from pydantic_core import PydanticUndefined, core_schema

Expand Down Expand Up @@ -111,8 +122,9 @@ def _validate(cls, v: Any, _):
@no_type_check
def get_schema_field(
field: DjangoField, *, depth: int = 0, optional: bool = False
) -> Tuple:
) -> Tuple[str, Type, FieldInfo]:
"Returns pydantic field from django's model field"
name = field.name
alias = None
default = ...
default_factory = None
Expand All @@ -124,15 +136,16 @@ def get_schema_field(

if field.is_relation:
if depth > 0:
return get_related_field_schema(field, depth=depth)
python_type, field_info = get_related_field_schema(field, depth=depth)
return name, python_type, field_info

internal_type = field.related_model._meta.pk.get_internal_type()

if not field.concrete and field.auto_created or field.null or optional:
default = None
nullable = True

alias = getattr(field, "get_attname", None) and field.get_attname()
name = getattr(field, "get_attname", None) and field.get_attname()

pk_type = TYPES.get(internal_type, int)
if field.one_to_many or field.many_to_many:
Expand Down Expand Up @@ -170,6 +183,7 @@ def get_schema_field(
title = title_if_lower(field.verbose_name)

return (
name,
python_type,
FieldInfo(
default=default,
Expand All @@ -185,7 +199,9 @@ def get_schema_field(


@no_type_check
def get_related_field_schema(field: DjangoField, *, depth: int) -> Tuple[OpenAPISchema]:
def get_related_field_schema(
field: DjangoField, *, depth: int
) -> Tuple[OpenAPISchema, FieldInfo]:
from ninja.orm import create_schema

model = field.related_model
Expand All @@ -204,3 +220,18 @@ def get_related_field_schema(field: DjangoField, *, depth: int) -> Tuple[OpenAPI
title=title_if_lower(field.verbose_name),
),
)


def get_field_property_accessors(field: DjangoField) -> Tuple[str, property]:
attribute_name = cast(
str, getattr(field, "get_attname", None) and field.get_attname()
)
property_name: str = field.name

def getter(self: BaseModel) -> Any:
return getattr(self, attribute_name)

def setter(self: BaseModel, value: Any) -> None:
setattr(self, attribute_name, value)

return property_name, property(getter, setter)
83 changes: 82 additions & 1 deletion tests/test_alias.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
from ninja import Field, NinjaAPI, Schema
import datetime

from django.db import models
from pydantic import ConfigDict
from pydantic.alias_generators import to_camel

from ninja import Field, ModelSchema, NinjaAPI, Schema


class SchemaWithAlias(Schema):
Expand Down Expand Up @@ -35,3 +41,78 @@ def test_alias():
# @api.post("/path", response=SchemaWithAlias)
# def alias_operation(request, payload: SchemaWithAlias):
# return {"bar": payload.foo}


def test_alias_foreignkey_schema():
class Author(models.Model):
id = models.AutoField(primary_key=True)
name = models.CharField(max_length=50)

class Meta:
app_label = "tests"

class Book(models.Model):
id = models.AutoField(primary_key=True)
name = models.CharField(max_length=100)
author = models.ForeignKey(Author, on_delete=models.CASCADE)
published_date = models.DateField(default=datetime.date(2024, 1, 1))

class Meta:
app_label = "tests"

class BookSchema(ModelSchema):
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)

class Meta:
model = Book
fields = "__all__"

assert BookSchema.json_schema() == {
"properties": {
"authorId": {"title": "Author", "type": "integer"},
"id": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Id"},
"name": {"maxLength": 100, "title": "Name", "type": "string"},
"publishedDate": {
"default": "2024-01-01",
"format": "date",
"title": "Published Date",
"type": "string",
},
},
"required": ["name", "authorId"],
"title": "BookSchema",
"type": "object",
}


def test_alias_foreignkey_property():
class Author(models.Model):
id = models.AutoField(primary_key=True)
name = models.CharField(max_length=50)

class Meta:
app_label = "tests"

class Book(models.Model):
id = models.AutoField(primary_key=True)
name = models.CharField(max_length=100)
author = models.ForeignKey(Author, on_delete=models.CASCADE)
published_date = models.DateField(default=datetime.date.today())

class Meta:
app_label = "tests"

class BookSchema(ModelSchema):
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)

class Meta:
model = Book
fields = "__all__"

author_test = Author(name="J. R. R. Tolkien", id=1)
model_test = Book(author=author_test, name="The Hobbit", id=1)
schema_test = BookSchema.from_orm(model_test)

schema_test.author = 2
assert schema_test.author == 2
assert schema_test.author_id == 2
Loading