diff --git a/docs/docs/guides/response/config-pydantic.md b/docs/docs/guides/response/config-pydantic.md index aff7a260e..2b82cc9e4 100644 --- a/docs/docs/guides/response/config-pydantic.md +++ b/docs/docs/guides/response/config-pydantic.md @@ -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): @@ -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 camel 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 @@ -55,13 +57,15 @@ results: ```JSON [ { - "Id": 1, - "Email": "tim@apple.com" + "id": 1, + "email": "tim@apple.com", + "createdDate": "2011-08-24" }, { - "Id": 2, - "Email": "sarah@smith.com" - } + "id": 2, + "email": "sarah@smith.com", + "createdDate": "2012-03-06" + }, ... ] diff --git a/ninja/orm/factory.py b/ninja/orm/factory.py index 3416df6ec..8dceda656 100644 --- a/ninja/orm/factory.py +++ b/ninja/orm/factory.py @@ -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: @@ -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: @@ -96,6 +97,15 @@ def create_schema( # **field_definitions: Any, self.schemas[key] = schema self.schema_names.add(name) + + # Create aliases for any foreign keys + if depth == 0: + for fld in model_fields_list: + # Do not create the alias if the user manually defined a field with the same name + if fld.is_relation and fld.name not in schema.model_fields: + prop = get_field_property_accessors(fld) + setattr(schema, fld.name, prop) + return schema def get_key( diff --git a/ninja/orm/fields.py b/ninja/orm/fields.py index d67814c8c..852ff845e 100644 --- a/ninja/orm/fields.py +++ b/ninja/orm/fields.py @@ -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 @@ -116,8 +127,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 @@ -129,7 +141,8 @@ 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() @@ -137,7 +150,7 @@ def get_schema_field( 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: @@ -183,6 +196,7 @@ def get_schema_field( title = title_if_lower(field.verbose_name) return ( + name, python_type, FieldInfo( default=default, @@ -198,7 +212,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 @@ -217,3 +233,17 @@ 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) -> property: + attribute_name = cast( + str, getattr(field, "get_attname", None) and field.get_attname() + ) + + def getter(self: BaseModel) -> Any: + return getattr(self, attribute_name) + + def setter(self: BaseModel, value: Any) -> None: + setattr(self, attribute_name, value) + + return property(getter, setter) diff --git a/tests/test_alias.py b/tests/test_alias.py index 3430ed6b4..61aa8aefd 100644 --- a/tests/test_alias.py +++ b/tests/test_alias.py @@ -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): @@ -35,3 +41,119 @@ 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 + + +def test_foreignkey_property_collision(): + """ + Ensure a foreign key's property alias does not override any user created fields + """ + + 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 AuthorSchema(ModelSchema): + class Meta: + model = Author + fields = ["name"] + + class BookSchema(ModelSchema): + author: AuthorSchema + + 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) + + assert schema_test.author_id == 1 + assert schema_test.author.name == "J. R. R. Tolkien"