Repository: vitalik/django-ninja

Issue:
Foreign key field value instead of id without nesting schemas ?
Hi, is it possible to send a foreign key value without nesting the schemas ? Here's my scenario: 

```python
class Department(models.Model):
    id = models.IntegerField(primarty_key=True, editable=False)
    dep = models.CharField(max_length=200, null=False,unique=True)

 class Student(models.Model):
    id = models.IntegerField(primarty_key=True, editable=False)
    name = models.CharField(max_length=200, null=False)
    dep = models.ForeignKey(Department, on_delete=models.CASCADE)


 class DepartmentOut(Schema):
     dep: str
 

 class StudentOut(Schema):
    id: int
    name: str
    dep: DepartmentOut

```
this will result in an output of: 
```python
{
    id: 1000,
    name: 'Some name',
    dep: {
        dep: 'Some Text'
        }
        
}
```

How can I show the dep string without a nested object called dep? Like the below shape of JSON:

```python
{
    id: 1000,
    name: 'Some name',
    dep: 'Some Text'
    
}
```
I tried searching without any luck, thanks in advance.

Comments:

Patch:
diff --git a/docs/docs/tutorial/response-schema.md b/docs/docs/tutorial/response-schema.md
index fdfbc23..fecd511 100644
--- a/docs/docs/tutorial/response-schema.md
+++ b/docs/docs/tutorial/response-schema.md
@@ -90,7 +90,7 @@ class TaskSchema(Schema):
 
 @api.get("/tasks", response=List[TaskSchema])
 def tasks(request):
-    queryset = Task.objects.all()
+    queryset = Task.objects.select_related("owner")
     return list(queryset)
 ```
 
@@ -117,6 +117,59 @@ If you execute this operation, you should get a response like this:
 ]
 ```
 
+
+## Aliases
+
+Instead of a nested response, you may want to just flatten the response output.
+The Ninja `Schema` object extends Pydantic's `Field(..., alias="")` format to
+work with dotted responses.
+
+Using the models from above, let's make a schema that just includes the task
+owner's first name inline, and also uses `completed` rather than `is_completed`:
+
+```Python hl_lines="1 7-9"
+from ninja import Field, Schema
+
+
+class TaskSchema(Schema):
+    id: int
+    title: str
+    # The first Field param is the default, use ... for required fields.
+    completed: bool = Field(..., alias="is_completed)
+    owner_first_name: str = Field(None, alias="owner.first_name")
+```
+
+
+## Resolvers
+
+You can also create calculated fields via resolve methods based on the field
+name.
+
+The method must accept a single argument, which will be the object the schema
+is resolving against.
+
+When creating a resolver as a standard method, `self` gives you access to other
+validated and formatted attributes in the schema.
+
+```Python hl_lines="5 7-11"
+class TaskSchema(Schema):
+    id: int
+    title: str
+    is_completed: bool
+    owner: Optional[str]
+    lower_title: str
+
+    @staticmethod
+    def resolve_owner(obj):
+        if not obj.owner:
+            return
+        return f"{obj.owner.first_name} {obj.owner.last_name}"
+
+    def resolve_lower_title(self, obj):
+        return self.title.lower()
+```
+
+
 ## Returning querysets
 
 In the previous example we specifically converted a queryset into a list (and executed the SQL query during evaluation).
diff --git a/ninja/orm/metaclass.py b/ninja/orm/metaclass.py
index 652b34c..1300238 100644
--- a/ninja/orm/metaclass.py
+++ b/ninja/orm/metaclass.py
@@ -1,16 +1,15 @@
 from typing import no_type_check
 
 from django.db.models import Model as DjangoModel
-from pydantic.main import ModelMetaclass
 
 from ninja.errors import ConfigError
 from ninja.orm.factory import create_schema
-from ninja.schema import Schema
+from ninja.schema import ResolverMetaclass, Schema
 
 _is_modelschema_class_defined = False
 
 
-class ModelSchemaMetaclass(ModelMetaclass):
+class ModelSchemaMetaclass(ResolverMetaclass):
     @no_type_check
     def __new__(
         mcs,
diff --git a/ninja/schema.py b/ninja/schema.py
index 73b762f..b86a722 100644
--- a/ninja/schema.py
+++ b/ninja/schema.py
@@ -1,9 +1,33 @@
-from typing import Any
+"""
+Since "Model" word would be very confusing when used in django context, this
+module basically makes an alias for it named "Schema" and adds extra whistles to
+be able to work with django querysets and managers.
+
+The schema is a bit smarter than a standard pydantic Model because it can handle
+dotted attributes and resolver methods. For example::
+
+
+    class UserSchema(User):
+        name: str
+        initials: str
+        boss: str = Field(None, alias="boss.first_name")
+
+        @staticmethod
+        def resolve_name(obj):
+            return f"{obj.first_name} {obj.last_name}"
+
+        def resolve_initials(self, obj):
+            return "".join(n[:1] for n in self.name.split())
+
+"""
+from operator import attrgetter
+from typing import Any, Callable, Dict, Type, TypeVar, Union, no_type_check
 
 import pydantic
 from django.db.models import Manager, QuerySet
 from django.db.models.fields.files import FieldFile
 from pydantic import BaseModel, Field, validator
+from pydantic.main import ModelMetaclass
 from pydantic.utils import GetterDict
 
 pydantic_version = list(map(int, pydantic.VERSION.split(".")[:2]))
@@ -11,16 +35,37 @@ assert pydantic_version >= [1, 6], "Pydantic 1.6+ required"
 
 __all__ = ["BaseModel", "Field", "validator", "DjangoGetter", "Schema"]
 
-
-# Since "Model" word would be very confusing when used in django context
-# this module basically makes alias for it named "Schema"
-# and ads extra whistles to be able to work with django querysets and managers
+S = TypeVar("S", bound="Schema")
 
 
 class DjangoGetter(GetterDict):
+    __slots__ = ("_obj", "_schema_cls")
+
+    def __init__(self, obj: Any, schema_cls: "Type[Schema]"):
+        self._obj = obj
+        self._schema_cls = schema_cls
+
+    def __getitem__(self, key: str) -> Any:
+        resolver = self._schema_cls._ninja_resolvers.get(key)
+        if resolver:
+            item = resolver(getter=self)
+        else:
+            try:
+                item = getattr(self._obj, key)
+            except AttributeError:
+                try:
+                    item = attrgetter(key)(self._obj)
+                except AttributeError as e:
+                    raise KeyError(key) from e
+        return self.format_result(item)
+
     def get(self, key: Any, default: Any = None) -> Any:
-        result = super().get(key, default)
+        try:
+            return self[key]
+        except KeyError:
+            return default
 
+    def format_result(self, result: Any) -> Any:
         if isinstance(result, Manager):
             return list(result.all())
 
@@ -35,7 +80,87 @@ class DjangoGetter(GetterDict):
         return result
 
 
-class Schema(BaseModel):
+class Resolver:
+    __slots__ = ("_func", "_static")
+    _static: bool
+    _func: Any
+
+    def __init__(self, func: Union[Callable, staticmethod]):
+        if isinstance(func, staticmethod):
+            self._static = True
+            self._func = func.__func__
+        else:
+            self._static = False
+            self._func = func
+
+    def __call__(self, getter: DjangoGetter) -> Any:
+        if self._static:
+            return self._func(getter._obj)
+        return self._func(self._fake_instance(getter), getter._obj)
+
+    def _fake_instance(self, getter: DjangoGetter) -> "Schema":
+        """
+        Generate a partial schema instance that can be used as the ``self``
+        attribute of resolver functions.
+        """
+
+        class PartialSchema(Schema):
+            def __getattr__(self, key: str) -> Any:
+                value = getter[key]
+                field = getter._schema_cls.__fields__[key]
+                value = field.validate(value, values={}, loc=key, cls=None)[0]
+                return value
+
+        return PartialSchema()
+
+
+class ResolverMetaclass(ModelMetaclass):
+    _ninja_resolvers: Dict[str, Resolver]
+
+    @no_type_check
+    def __new__(cls, name, bases, namespace, **kwargs):
+        resolvers = {}
+
+        for base in reversed(bases):
+            base_resolvers = getattr(base, "_ninja_resolvers", None)
+            if base_resolvers:
+                resolvers.update(base_resolvers)
+        for attr, resolve_func in namespace.items():
+            if not attr.startswith("resolve_"):
+                continue
+            if (
+                not callable(resolve_func)
+                # A staticmethod isn't directly callable in Python <=3.9.
+                and not isinstance(resolve_func, staticmethod)
+            ):
+                continue
+            resolvers[attr[8:]] = Resolver(resolve_func)
+
+        result = super().__new__(cls, name, bases, namespace, **kwargs)
+        result._ninja_resolvers = resolvers
+        return result
+
+
+class Schema(BaseModel, metaclass=ResolverMetaclass):
     class Config:
         orm_mode = True
         getter_dict = DjangoGetter
+
+    @classmethod
+    def from_orm(cls: Type[S], obj: Any) -> S:
+        getter_dict = cls.__config__.getter_dict
+        obj = (
+            # DjangoGetter also needs the class so it can find resolver methods.
+            getter_dict(obj, cls)
+            if issubclass(getter_dict, DjangoGetter)
+            else getter_dict(obj)
+        )
+        return super().from_orm(obj)
+
+    @classmethod
+    def _decompose_class(cls, obj: Any) -> GetterDict:
+        # This method has backported logic from Pydantic 1.9 and is no longer
+        # needed once that is the minimum version.
+        if isinstance(obj, GetterDict):
+            return obj
+        return super()._decompose_class(obj)  # pragma: no cover


Test patch:
diff --git a/tests/test_schema.py b/tests/test_schema.py
index fa68982..0ae5d02 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -1,4 +1,4 @@
-from typing import List
+from typing import List, Optional
 from unittest.mock import Mock
 
 from django.db.models import Manager, QuerySet
@@ -34,11 +34,16 @@ class Tag:
         self.title = title
 
 
-# mocking some user:
+# mocking some users:
+class Boss:
+    name = "Jane Jackson"
+
+
 class User:
-    name = "John"
+    name = "John Smith"
     group_set = FakeManager([1, 2, 3])
     avatar = ImageFieldFile(None, Mock(), name=None)
+    boss: Optional[Boss] = Boss()
 
     @property
     def tags(self):
@@ -57,11 +62,27 @@ class UserSchema(Schema):
     avatar: str = None
 
 
+class UserWithBossSchema(UserSchema):
+    boss: Optional[str] = Field(None, alias="boss.name")
+    has_boss: bool
+
+    @staticmethod
+    def resolve_has_boss(obj):
+        return bool(obj.boss)
+
+
+class UserWithInitialsSchema(UserWithBossSchema):
+    initials: str
+
+    def resolve_initials(self, obj):
+        return "".join(n[:1] for n in self.name.split())
+
+
 def test_schema():
     user = User()
     schema = UserSchema.from_orm(user)
     assert schema.dict() == {
-        "name": "John",
+        "name": "John Smith",
         "groups": [1, 2, 3],
         "tags": [{"id": "1", "title": "foo"}, {"id": "2", "title": "bar"}],
         "avatar": None,
@@ -75,8 +96,47 @@ def test_schema_with_image():
     user.avatar = ImageFieldFile(None, field, name="smile.jpg")
     schema = UserSchema.from_orm(user)
     assert schema.dict() == {
-        "name": "John",
+        "name": "John Smith",
         "groups": [1, 2, 3],
         "tags": [{"id": "1", "title": "foo"}, {"id": "2", "title": "bar"}],
         "avatar": "/smile.jpg",
     }
+
+
+def test_with_boss_schema():
+    user = User()
+    schema = UserWithBossSchema.from_orm(user)
+    assert schema.dict() == {
+        "name": "John Smith",
+        "boss": "Jane Jackson",
+        "has_boss": True,
+        "groups": [1, 2, 3],
+        "tags": [{"id": "1", "title": "foo"}, {"id": "2", "title": "bar"}],
+        "avatar": None,
+    }
+
+    user_without_boss = User()
+    user_without_boss.boss = None
+    schema = UserWithBossSchema.from_orm(user_without_boss)
+    assert schema.dict() == {
+        "name": "John Smith",
+        "boss": None,
+        "has_boss": False,
+        "groups": [1, 2, 3],
+        "tags": [{"id": "1", "title": "foo"}, {"id": "2", "title": "bar"}],
+        "avatar": None,
+    }
+
+
+def test_with_initials_schema():
+    user = User()
+    schema = UserWithInitialsSchema.from_orm(user)
+    assert schema.dict() == {
+        "name": "John Smith",
+        "initials": "JS",
+        "boss": "Jane Jackson",
+        "has_boss": True,
+        "groups": [1, 2, 3],
+        "tags": [{"id": "1", "title": "foo"}, {"id": "2", "title": "bar"}],
+        "avatar": None,
+    }