From 1717cb704a31b4ac11825546f6f1bbbf99018718 Mon Sep 17 00:00:00 2001 From: Peter DeVita Date: Thu, 21 Mar 2024 22:55:06 -0400 Subject: [PATCH 1/9] Use property to alias ForeignKey fields instead of Pydantic alias --- ninja/orm/factory.py | 12 +++++++++--- ninja/orm/fields.py | 15 +++++++++++++-- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/ninja/orm/factory.py b/ninja/orm/factory.py index 3416df6e..2935fbab 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_schema_field, get_field_property_accessors from ninja.schema import Schema # MAYBE: @@ -62,12 +62,12 @@ def create_schema( definitions = {} for fld in model_fields_list: - python_type, field_info = get_schema_field( + 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 +96,12 @@ def create_schema( # **field_definitions: Any, self.schemas[key] = schema self.schema_names.add(name) + + 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( diff --git a/ninja/orm/fields.py b/ninja/orm/fields.py index a0c50341..58fffe4e 100644 --- a/ninja/orm/fields.py +++ b/ninja/orm/fields.py @@ -108,6 +108,7 @@ def get_schema_field( field: DjangoField, *, depth: int = 0, optional: bool = False ) -> Tuple: "Returns pydantic field from django's model field" + name = field.name alias = None default = ... default_factory = None @@ -118,14 +119,14 @@ def get_schema_field( if field.is_relation: if depth > 0: - return get_related_field_schema(field, depth=depth) + return name, *get_related_field_schema(field, depth=depth) internal_type = field.related_model._meta.pk.get_internal_type() if not field.concrete and field.auto_created or field.null: default = None - 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: @@ -165,6 +166,7 @@ def get_schema_field( title = title_if_lower(field.verbose_name) return ( + name, python_type, FieldInfo( default=default, @@ -199,3 +201,12 @@ def get_related_field_schema(field: DjangoField, *, depth: int) -> Tuple[OpenAPI title=title_if_lower(field.verbose_name), ), ) + + +@no_type_check +def get_field_property_accessors(field: DjangoField): + attribute_name = getattr(field, "get_attname", None) and field.get_attname() + property_name = field.name + fget = lambda self: getattr(self, attribute_name) + fset = lambda self, value: setattr(self, attribute_name, value) + return property_name, property(fget, fset) From c3a2c1e68491e662f312264f7f8b9f737229ac56 Mon Sep 17 00:00:00 2001 From: Peter DeVita Date: Thu, 21 Mar 2024 23:12:21 -0400 Subject: [PATCH 2/9] Don't overwrite FK fields at depth > 0 --- ninja/orm/factory.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/ninja/orm/factory.py b/ninja/orm/factory.py index 2935fbab..055b7d10 100644 --- a/ninja/orm/factory.py +++ b/ninja/orm/factory.py @@ -97,10 +97,11 @@ def create_schema( self.schemas[key] = schema self.schema_names.add(name) - for fld in model_fields_list: - if fld.is_relation: - field_name, prop = get_field_property_accessors(fld) - setattr(schema, field_name, prop) + 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 From c190dc7e01155772b3577690aab2cf95dc4144c3 Mon Sep 17 00:00:00 2001 From: Peter DeVita Date: Fri, 22 Mar 2024 14:21:48 -0400 Subject: [PATCH 3/9] Fix <3.8 compatibility, fix typing issue --- ninja/orm/fields.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ninja/orm/fields.py b/ninja/orm/fields.py index 58fffe4e..3a0f83b9 100644 --- a/ninja/orm/fields.py +++ b/ninja/orm/fields.py @@ -119,7 +119,8 @@ def get_schema_field( if field.is_relation: if depth > 0: - return name, *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() @@ -182,7 +183,7 @@ 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 From 1a98e3d14efca010d1d2e1c03adf41d6cfa5c2bc Mon Sep 17 00:00:00 2001 From: Peter DeVita Date: Fri, 22 Mar 2024 14:25:36 -0400 Subject: [PATCH 4/9] Fix linting --- ninja/orm/factory.py | 2 +- ninja/orm/fields.py | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/ninja/orm/factory.py b/ninja/orm/factory.py index 055b7d10..40358a65 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, get_field_property_accessors +from ninja.orm.fields import get_field_property_accessors, get_schema_field from ninja.schema import Schema # MAYBE: diff --git a/ninja/orm/fields.py b/ninja/orm/fields.py index 3a0f83b9..18f6a994 100644 --- a/ninja/orm/fields.py +++ b/ninja/orm/fields.py @@ -105,7 +105,7 @@ def _validate(cls, v: Any, _): @no_type_check def get_schema_field( - field: DjangoField, *, depth: int = 0, optional: bool = False + field: DjangoField, *, depth: int = 0, optional: bool = False ) -> Tuple: "Returns pydantic field from django's model field" name = field.name @@ -208,6 +208,11 @@ def get_related_field_schema(field: DjangoField, *, depth: int) -> Tuple[OpenAPI def get_field_property_accessors(field: DjangoField): attribute_name = getattr(field, "get_attname", None) and field.get_attname() property_name = field.name - fget = lambda self: getattr(self, attribute_name) - fset = lambda self, value: setattr(self, attribute_name, value) - return property_name, property(fget, fset) + + def getter(self): + getattr(self, attribute_name) + + def setter(self, value): + setattr(self, attribute_name, value) + + return property_name, property(getter, setter) From 8bf10e52dda7c64692a7a2387527d15725a8fd8a Mon Sep 17 00:00:00 2001 From: Peter DeVita Date: Fri, 22 Mar 2024 17:54:38 -0400 Subject: [PATCH 5/9] Add tests, fixes --- ninja/orm/factory.py | 1 + ninja/orm/fields.py | 38 +++++++++++++------- tests/test_alias.py | 82 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 108 insertions(+), 13 deletions(-) diff --git a/ninja/orm/factory.py b/ninja/orm/factory.py index 40358a65..c9fe9bf5 100644 --- a/ninja/orm/factory.py +++ b/ninja/orm/factory.py @@ -62,6 +62,7 @@ def create_schema( definitions = {} for fld in model_fields_list: + # types: ignore field_name, python_type, field_info = get_schema_field( fld, depth=depth, diff --git a/ninja/orm/fields.py b/ninja/orm/fields.py index 18f6a994..b4c8a025 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 @@ -105,8 +116,8 @@ def _validate(cls, v: Any, _): @no_type_check def get_schema_field( - field: DjangoField, *, depth: int = 0, optional: bool = False -) -> Tuple: + field: DjangoField, *, depth: int = 0, optional: bool = False +) -> Tuple[str, Type, FieldInfo]: "Returns pydantic field from django's model field" name = field.name alias = None @@ -183,7 +194,9 @@ def get_schema_field( @no_type_check -def get_related_field_schema(field: DjangoField, *, depth: int) -> Tuple[OpenAPISchema, FieldInfo]: +def get_related_field_schema( + field: DjangoField, *, depth: int +) -> Tuple[OpenAPISchema, FieldInfo]: from ninja.orm import create_schema model = field.related_model @@ -204,15 +217,16 @@ def get_related_field_schema(field: DjangoField, *, depth: int) -> Tuple[OpenAPI ) -@no_type_check -def get_field_property_accessors(field: DjangoField): - attribute_name = getattr(field, "get_attname", None) and field.get_attname() - property_name = field.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): - getattr(self, attribute_name) + def getter(self: BaseModel) -> Any: + return getattr(self, attribute_name) - def setter(self, value): + def setter(self: BaseModel, value: Any) -> None: setattr(self, attribute_name, value) return property_name, property(getter, setter) diff --git a/tests/test_alias.py b/tests/test_alias.py index 3430ed6b..5f143cc0 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,77 @@ 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.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__" + + 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-03-22", + "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 From 48ddb02a44874dc757d1cf96ec448d0c3447df1b Mon Sep 17 00:00:00 2001 From: Peter DeVita Date: Fri, 22 Mar 2024 18:21:48 -0400 Subject: [PATCH 6/9] Improve docs, tests --- docs/docs/guides/response/config-pydantic.md | 38 +++++++++++--------- tests/test_alias.py | 5 ++- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/docs/docs/guides/response/config-pydantic.md b/docs/docs/guides/response/config-pydantic.md index 57b34aac..655cd4bf 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): @@ -33,15 +32,18 @@ class CamelModelSchema(Schema): !!! note When overriding the schema's `Config`, it is necessary to inherit from the base `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 @@ -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/tests/test_alias.py b/tests/test_alias.py index 5f143cc0..e69e72fa 100644 --- a/tests/test_alias.py +++ b/tests/test_alias.py @@ -103,7 +103,9 @@ class Meta: app_label = "tests" class BookSchema(ModelSchema): - model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + class Config(Schema.Config): + alias_generator = to_camel + populate_by_name = True class Meta: model = Book @@ -115,3 +117,4 @@ class Meta: schema_test.author = 2 assert schema_test.author == 2 + assert schema_test.author_id == 2 From 6ef7f2739e24b3f5f13a492115cf463ecd650cba Mon Sep 17 00:00:00 2001 From: Peter DeVita Date: Fri, 2 Aug 2024 18:52:02 -0400 Subject: [PATCH 7/9] Fix FK alias test using today's date --- tests/test_alias.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_alias.py b/tests/test_alias.py index e69e72fa..0c776378 100644 --- a/tests/test_alias.py +++ b/tests/test_alias.py @@ -55,7 +55,7 @@ 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()) + published_date = models.DateField(default=datetime.date(2024, 1, 1)) class Meta: app_label = "tests" @@ -73,7 +73,7 @@ class Meta: "id": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Id"}, "name": {"maxLength": 100, "title": "Name", "type": "string"}, "publishedDate": { - "default": "2024-03-22", + "default": "2024-01-01", "format": "date", "title": "Published Date", "type": "string", From b148479316db503babb01f4f57ed7d2fb2ca2946 Mon Sep 17 00:00:00 2001 From: Peter DeVita Date: Fri, 2 Aug 2024 19:00:08 -0400 Subject: [PATCH 8/9] Swap FK alias property test to use ConfigDict --- tests/test_alias.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_alias.py b/tests/test_alias.py index 0c776378..a15b2df6 100644 --- a/tests/test_alias.py +++ b/tests/test_alias.py @@ -103,9 +103,7 @@ class Meta: app_label = "tests" class BookSchema(ModelSchema): - class Config(Schema.Config): - alias_generator = to_camel - populate_by_name = True + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) class Meta: model = Book From bf1b7492faf39459155443bbeba91cb5139c4b45 Mon Sep 17 00:00:00 2001 From: Peter DeVita Date: Fri, 2 Aug 2024 19:03:17 -0400 Subject: [PATCH 9/9] Small docs improvement to config-pydantic --- docs/docs/guides/response/config-pydantic.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/guides/response/config-pydantic.md b/docs/docs/guides/response/config-pydantic.md index 655cd4bf..2b82cc9e 100644 --- a/docs/docs/guides/response/config-pydantic.md +++ b/docs/docs/guides/response/config-pydantic.md @@ -30,7 +30,7 @@ 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. 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.