diff --git a/docs/docs/guides/response/config-pydantic.md b/docs/docs/guides/response/config-pydantic.md
index eabe8f984..c79f5aff1 100644
--- a/docs/docs/guides/response/config-pydantic.md
+++ b/docs/docs/guides/response/config-pydantic.md
@@ -1,7 +1,10 @@
# Overriding Pydantic Config
There are many customizations available for a **Django Ninja `Schema`**, via the schema's
-[Pydantic `Config` class](https://pydantic-docs.helpmanual.io/usage/model_config/).
+[Pydantic `Config` class/`model_config` dict attr](https://pydantic-docs.helpmanual.io/usage/model_config/).
+
+!!! Warning
+ Using a `Config` class is [deprecated in pydantic v2](https://docs.pydantic.dev/latest/concepts/config/).
!!! info
Under the hood **Django Ninja** uses [Pydantic Models](https://pydantic-docs.helpmanual.io/usage/models/)
@@ -22,27 +25,27 @@ def to_camel(string: str) -> str:
words = string.split('_')
return words[0].lower() + ''.join(word.capitalize() for word in words[1:])
-class CamelModelSchema(Schema):
+class CamelSchema(Schema):
str_field_name: str
float_field_name: float
- class Config(Schema.Config):
+ class Config:
alias_generator = to_camel
```
-!!! 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 camel case) - you need to set as well `populate_by_name` and `by_alias`
```python hl_lines="6 9"
class UserSchema(ModelSchema):
class Config:
- model = User
- model_fields = ["id", "email", "is_staff"]
+ """Pydantic config"""
alias_generator = to_camel
populate_by_name = True # !!!!!! <--------
-
+
+ class Meta:
+ """ModelSchema config"""
+ model = User
+ model_fields = ["id", "email", "is_staff"]
@api.get("/users", response=list[UserSchema], by_alias=True) # !!!!!! <-------- by_alias
def get_users(request):
@@ -72,9 +75,11 @@ results:
## Custom Config from Django Model
When using [`create_schema`](django-pydantic-create-schema.md#create_schema), the resulting
-schema can be used to build another class with a custom config like:
+schema can be used to build another class with a custom config and/or more keys. However,
+the model and fields being used with the model cannot be modified as this would require
+an explicit `Meta` config class to be defined and inherited from.
-```python hl_lines="10"
+```python hl_lines="9"
from django.contrib.auth.models import User
from ninja.orm import create_schema
@@ -83,7 +88,7 @@ BaseUserSchema = create_schema(User)
class UserSchema(BaseUserSchema):
-
- class Config(BaseUserSchema.Config):
- ...
+ model_config = ConfigDict() # any necessary pydantic config
+ related_item: RelatedItemModelSchema
+ stringkey: str
```
diff --git a/docs/docs/guides/response/django-pydantic-create-schema.md b/docs/docs/guides/response/django-pydantic-create-schema.md
index f33c7bac6..08eef2ee2 100644
--- a/docs/docs/guides/response/django-pydantic-create-schema.md
+++ b/docs/docs/guides/response/django-pydantic-create-schema.md
@@ -1,7 +1,7 @@
# Using create_schema
-Under the hood, [`ModelSchema`](django-pydantic.md#modelschema) uses the `create_schema` function.
-This is a more advanced (and less safe) method - please use it carefully.
+This is a more advanced (and less safe) method - please use it carefully. It is usually better
+to create `ModelSchema` classes with `Meta` configs explicitly.
## `create_schema`
@@ -19,7 +19,6 @@ def create_schema(
)
```
-
Take this example:
```python hl_lines="2 4"
diff --git a/docs/docs/guides/response/django-pydantic.md b/docs/docs/guides/response/django-pydantic.md
index 6ab7e7ced..a02ea49f9 100644
--- a/docs/docs/guides/response/django-pydantic.md
+++ b/docs/docs/guides/response/django-pydantic.md
@@ -5,11 +5,26 @@ Schemas are very useful to define your validation rules and responses, but somet
## ModelSchema
-`ModelSchema` is a special base class that can automatically generate schemas from your models.
+`ModelSchema` is a special base class that can automatically generate schemas from your models. Under the hood it converts your models Django fields into
+pydantic type annotations. `ModelSchema` inherits from `Schema`, and is just a `Schema` with a Django field -> pydantic field conversion step. All other `Schema`
+related configuration and inheritance is the same.
+
+### Configuration
+
+To configure a `ModelSchema` you define a `Meta` class attribute just like in Django. This `Meta` class will be validated by `ninja.orm.metaclass.MetaConf`.
+
+```Python
+class MetaConf: # summary
+ model: Django model being used to create the Schema
+ fields: List of field names in the model to use. Defaults to '__all__' which includes all fields
+ exclude: List of field names to exclude
+ optional_fields: List of field names which will be optional, can also take '__all__'
+ depth: If > 0 schema will also be created for the nested ForeignKeys and Many2Many (with the provided depth of lookup)
+ primary_key_optional: Defaults to True, controls if django's primary_key=True field in the provided model is required
+```
All you need is to set `model` and `fields` attributes on your schema `Meta`:
-
```python hl_lines="2 5 6 7"
from django.contrib.auth.models import User
from ninja import ModelSchema
@@ -28,6 +43,117 @@ class UserSchema(ModelSchema):
# last_name: str
```
+### Non-Django Model Configuration
+
+The `Meta` class is only used for configuring the interaction between the django model and the underlying
+`Schema`. To configure the pydantic model underlying the `Schema` define, `model_config` in your
+`ModelSchema` class, or [use the deprecated by pydantic `class Config`](https://docs.pydantic.dev/latest/concepts/config/).
+
+```Python
+class UserSlimGetSchema(ModelSchema):
+ # pydantic config
+ # --
+ model_config = {"validate_default": True}
+ # OR
+ class Config:
+ validate_default = True
+ # --
+
+ class Meta:
+ model = User
+ fields = ["id", "name"]
+```
+
+### Inheritance
+
+Because a `ModelSchema` is just a child of `Schema`, which is in turn just a child of pydantic `BaseModel`, you
+can do some convenient inheritance to handle more advanced configuration scenarios.
+
+!!! Warning
+ Beware that pydantic v2 does not always respect MRO: https://github.com/pydantic/pydantic/issues/9992
+
+```python
+ from ninja import Schema, ModelSchema
+ from pydantic import model_serializer
+ from django.db import models
+
+ #
+ def _my_magic_serializer(self, handler):
+ dump = handler(self)
+ dump["magic"] = "shazam"
+ return dump
+
+
+ class ProjSchema(Schema):
+ # pydantic configuration
+ _my_magic_serilizer = model_serializer(mode="wrap")(_my_magic_serializer)
+ model_config = {"arbitrary_types_allowed": True}
+
+
+ class ProjModelSchema(ProjSchema, ModelSchema):
+ # ModelSchema specific configuration
+ pass
+
+
+ class ProjMeta:
+ # ModelSchema Meta defaults
+ primary_key_optional = False
+
+ #
+
+
+ #
+ class Item(models.Model):
+ name = models.CharField(max_length=64)
+ type = models.CharField(max_length=64)
+ desc = models.CharField(max_length=255, blank=True, null=True)
+
+ class Meta:
+ app_label = "test"
+
+
+ class Event(models.Model):
+ name = models.CharField(max_length=64)
+ action = models.CharField(max_length=64)
+
+ class Meta:
+ app_label = "test"
+
+ #
+
+
+ #
+ # All schemas will be using the configuration defined in parent Schemas
+ class ItemSlimGetSchema(ProjModelSchema):
+ class Meta(ProjMeta):
+ model = Item
+ fields = ["id", "name"]
+
+
+ class ItemGetSchema(ItemSlimGetSchema):
+ class Meta(ItemSlimGetSchema.Meta):
+ # inherits model, and the parents fields are already set in __annotations__
+ fields = ["type", "desc"]
+
+
+ class EventGetSchema(ProjModelSchema):
+ class Meta(ProjMeta):
+ model = Event
+ fields = ["id", "name"]
+
+
+ class ItemSummarySchema(ProjSchema):
+ model_config = {
+ "title": "Item Summary"
+ }
+ name: str
+ event: EventGetSchema
+ item: ItemGetSchema
+
+ #
+```
+
+
### Using ALL model fields
To use all fields from a model - you can pass `__all__` to `fields`:
@@ -70,7 +196,8 @@ class UserSchema(ModelSchema):
### Overriding fields
-To change default annotation for some field, or to add a new field, just use annotated attributes as usual.
+To change default annotation for some field, or to add a new field, just use annotated attributes as usual since a `ModelSchema` is
+in the end just a `Schema`.
```python hl_lines="1 2 3 4 8"
class GroupSchema(ModelSchema):
@@ -122,11 +249,8 @@ def patch(request, pk: int, payload: PatchGroupSchema):
setattr(obj, attr, value)
obj.save()
-
-
```
-
### Custom fields types
For each Django field it encounters, `ModelSchema` uses the default `Field.get_internal_type` method
@@ -144,7 +268,6 @@ class MyModel(models.Modle):
from ninja.orm import register_field
register_field('VectorField', list[float])
-
```
#### PatchDict
@@ -170,7 +293,32 @@ def modify_data(request, pk: int, payload: PatchDict[GroupSchema]):
setattr(obj, attr, value)
obj.save()
-
```
in this example the `payload` argument will be a type of `dict` only fields that were passed in request and validated using `GroupSchema`
+
+
+### Inheritance
+
+ModelSchemas can utilize inheritance. The `Meta` class is not inherited implicitly and must have an explicit parent if desired.
+
+```Python
+class ProjectBaseSchema(Schema):
+ # global pydantic config, hooks, etc
+ model_config = {}
+
+class ProjectBaseModelSchema(ModelSchema, ProjectBaseSchema):
+
+ class Meta:
+ primary_key_optional = False
+
+class UserSlimGetSchema(ProjectBaseModelSchema):
+ class Meta(ProjectBaseModelSchema.Meta):
+ model = User
+ fields = ["id", "username"]
+
+class UserFullGetSchema(UserSlimGetSchema):
+ class Meta(UserSlimGetSchema.Meta):
+ model = Item
+ fields = ["id", "slug"]
+```
\ No newline at end of file
diff --git a/ninja/orm/factory.py b/ninja/orm/factory.py
index 3416df6ec..44498147f 100644
--- a/ninja/orm/factory.py
+++ b/ninja/orm/factory.py
@@ -1,9 +1,21 @@
import itertools
-from typing import Any, Dict, Iterator, List, Optional, Set, Tuple, Type, Union, cast
+from typing import (
+ Any,
+ Dict,
+ Iterator,
+ List,
+ Optional,
+ Set,
+ Tuple,
+ Type,
+ Union,
+ cast,
+)
from django.db.models import Field as DjangoField
from django.db.models import ManyToManyRel, ManyToOneRel, Model
from pydantic import create_model as create_pydantic_model
+from typing_extensions import Literal
from ninja.errors import ConfigError
from ninja.orm.fields import get_schema_field
@@ -38,36 +50,31 @@ def create_schema(
*,
name: str = "",
depth: int = 0,
- fields: Optional[List[str]] = None,
+ fields: Optional[Union[List[str], Literal["__all__"]]] = "__all__",
exclude: Optional[List[str]] = None,
optional_fields: Optional[List[str]] = None,
custom_fields: Optional[List[Tuple[str, Any, Any]]] = None,
base_class: Type[Schema] = Schema,
+ primary_key_optional: bool = True,
) -> Type[Schema]:
name = name or model.__name__
- if fields and exclude:
- raise ConfigError("Only one of 'fields' or 'exclude' should be set.")
-
key = self.get_key(
model, name, depth, fields, exclude, optional_fields, custom_fields
)
- if key in self.schemas:
- return self.schemas[key]
- model_fields_list = list(self._selected_model_fields(model, fields, exclude))
- if optional_fields:
- if optional_fields == "__all__":
- optional_fields = [f.name for f in model_fields_list]
+ schema = self.get_schema(key)
+ if schema is not None:
+ return schema
- definitions = {}
- for fld in model_fields_list:
- 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 = self.convert_django_fields(
+ model,
+ depth=depth,
+ fields=fields,
+ exclude=exclude,
+ optional_fields=optional_fields,
+ primary_key_optional=primary_key_optional,
+ )
if custom_fields:
for fld_name, python_type, field_info in custom_fields:
@@ -78,7 +85,7 @@ def create_schema(
if name in self.schema_names:
name = self._get_unique_name(name)
- schema: Type[Schema] = create_pydantic_model(
+ schema = create_pydantic_model(
name,
__config__=None,
__base__=base_class,
@@ -86,24 +93,51 @@ def create_schema(
__validators__={},
**definitions,
) # type: ignore
- # __model_name: str,
- # *,
- # __config__: ConfigDict | None = None,
- # __base__: None = None,
- # __module__: str = __name__,
- # __validators__: dict[str, AnyClassMethod] | None = None,
- # __cls_kwargs__: dict[str, Any] | None = None,
- # **field_definitions: Any,
+
self.schemas[key] = schema
self.schema_names.add(name)
return schema
+ def get_schema(self, key: SchemaKey) -> Union[Type[Schema], None]:
+ if key in self.schemas:
+ return self.schemas[key]
+ return None
+
+ def convert_django_fields(
+ self,
+ model: Type[Model],
+ *,
+ depth: int = 0,
+ fields: Optional[Union[List[str], Literal["__all__"]]] = None,
+ exclude: Optional[List[str]] = None,
+ optional_fields: Optional[List[str]] = None,
+ primary_key_optional: bool = True,
+ ) -> Dict[str, Tuple[Any, Any]]:
+ if (fields and fields != "__all__") and exclude:
+ raise ConfigError("Only one of 'fields' or 'exclude' should be set.")
+
+ model_fields_list = list(self._selected_model_fields(model, fields, exclude))
+ if optional_fields and optional_fields == "__all__":
+ optional_fields = [f.name for f in model_fields_list]
+
+ definitions = {}
+ for fld in model_fields_list:
+ python_type, field_info = get_schema_field(
+ fld,
+ depth=depth,
+ optional=optional_fields and (fld.name in optional_fields),
+ primary_key_optional=primary_key_optional,
+ )
+ definitions[fld.name] = (python_type, field_info)
+
+ return definitions
+
def get_key(
self,
model: Type[Model],
name: str,
depth: int,
- fields: Union[str, List[str], None],
+ fields: Optional[Union[List[str], Literal["__all__"]]],
exclude: Optional[List[str]],
optional_fields: Optional[Union[List[str], str]],
custom_fields: Optional[List[Tuple[str, str, Any]]],
@@ -131,15 +165,19 @@ def _get_unique_name(self, name: str) -> str:
def _selected_model_fields(
self,
model: Type[Model],
- fields: Optional[List[str]] = None,
+ fields: Optional[Union[List[str], Literal["__all__"]]] = None,
exclude: Optional[List[str]] = None,
) -> Iterator[DjangoField]:
"Returns iterator for model fields based on `exclude` or `fields` arguments"
all_fields = {f.name: f for f in self._model_fields(model)}
+ if fields == "__all__":
+ fields = None
+
if not fields and not exclude:
for f in all_fields.values():
yield f
+ return
invalid_fields = (set(fields or []) | set(exclude or [])) - all_fields.keys()
if invalid_fields:
diff --git a/ninja/orm/fields.py b/ninja/orm/fields.py
index d67814c8c..4ea9c4f7c 100644
--- a/ninja/orm/fields.py
+++ b/ninja/orm/fields.py
@@ -115,7 +115,11 @@ 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,
+ primary_key_optional: bool = True,
) -> Tuple:
"Returns pydantic field from django's model field"
alias = None
@@ -163,7 +167,7 @@ def get_schema_field(
]
raise ConfigError("\n".join(msg)) from e
- if field.primary_key or blank or null or optional:
+ if (field.primary_key and primary_key_optional) or blank or null or optional:
default = None
nullable = True
diff --git a/ninja/orm/metaclass.py b/ninja/orm/metaclass.py
index 84605aa69..1e7c00250 100644
--- a/ninja/orm/metaclass.py
+++ b/ninja/orm/metaclass.py
@@ -1,68 +1,72 @@
import warnings
-from typing import Any, List, Optional, Union, no_type_check
+from inspect import getmembers
+from typing import List, Optional, Type, Union, no_type_check
from django.db.models import Model as DjangoModel
-from pydantic.dataclasses import dataclass
+from pydantic import (
+ AliasChoices,
+ BaseModel,
+ ConfigDict,
+ Field,
+ ValidationError,
+ model_validator,
+)
+from typing_extensions import Literal, Self
from ninja.errors import ConfigError
-from ninja.orm.factory import create_schema
+from ninja.orm.factory import factory
from ninja.schema import ResolverMetaclass, Schema
-_is_modelschema_class_defined = False
-
-
-@dataclass
-class MetaConf:
- model: Any
- fields: Optional[List[str]] = None
- exclude: Union[List[str], str, None] = None
- fields_optional: Union[List[str], str, None] = None
-
- @staticmethod
- def from_schema_class(name: str, namespace: dict) -> "MetaConf":
- if "Meta" in namespace:
- meta = namespace["Meta"]
- model = meta.model
- fields = getattr(meta, "fields", None)
- exclude = getattr(meta, "exclude", None)
- optional_fields = getattr(meta, "fields_optional", None)
-
- elif "Config" in namespace:
- config = namespace["Config"]
- model = config.model
- fields = getattr(config, "model_fields", None)
- exclude = getattr(config, "model_exclude", None)
- optional_fields = getattr(config, "model_fields_optional", None)
+class MetaConf(BaseModel):
+ """
+ Mirrors the relevant arguments for create_schema
+
+ model: Django model being used to create the Schema
+ fields: List of field names in the model to use. Defaults to '__all__' which includes all fields
+ exclude: List of field names to exclude
+ optional_fields: List of field names which will be optional, can also take '__all__'
+ depth: If > 0 schema will also be created for the nested ForeignKeys and Many2Many (with the provided depth of lookup)
+ primary_key_optional: Defaults to True, controls if django's primary_key=True field in the provided model is required
+
+ fields_optional: same as optional_fields, deprecated in order to match `create_schema()` API
+ """
+
+ model: Optional[Type[DjangoModel]] = None
+ # aliased for Config
+ fields: Union[List[str], Literal["__all__"], None] = Field(
+ None, validation_alias=AliasChoices("fields", "model_fields")
+ )
+ exclude: Optional[List[str]] = None
+ optional_fields: Union[List[str], Literal["__all__"], None] = None
+ depth: int = 0
+ primary_key_optional: Optional[bool] = None
+ # deprecated
+ fields_optional: Union[List[str], Literal["__all__"], None] = Field(
+ default=None, exclude=True
+ )
+
+ model_config = ConfigDict(extra="forbid")
+
+ @model_validator(mode="after")
+ def check_fields(self) -> Self:
+ if self.model and (
+ (not self.exclude and not self.fields) or (self.exclude and self.fields)
+ ):
+ raise ConfigError("Specify either `exclude` or `fields`")
+
+ if self.fields_optional:
+ if self.optional_fields is not None:
+ raise ConfigError(
+ "Use only `optional_fields`, `fields_optional` is deprecated."
+ )
warnings.warn(
- "The use of `Config` class is deprecated for ModelSchema, use 'Meta' instead",
+ "The use of `fields_optional` is deprecated. Use `optional_fields` instead to match `create_schema()` API",
DeprecationWarning,
stacklevel=2,
)
-
- else:
- raise ConfigError(
- f"ModelSchema class '{name}' requires a 'Meta' (or a 'Config') subclass"
- )
-
- assert issubclass(model, DjangoModel)
-
- if not fields and not exclude:
- raise ConfigError(
- "Creating a ModelSchema without either the 'fields' attribute"
- " or the 'exclude' attribute is prohibited"
- )
-
- if fields == "__all__":
- fields = None
- # ^ when None is passed to create_schema - all fields are selected
-
- return MetaConf(
- model=model,
- fields=fields,
- exclude=exclude,
- fields_optional=optional_fields,
- )
+ self.optional_fields = self.fields_optional
+ return self
class ModelSchemaMetaclass(ResolverMetaclass):
@@ -74,6 +78,62 @@ def __new__(
namespace: dict,
**kwargs,
):
+ meta_conf = None
+
+ if "Config" in namespace:
+ config_keys = {k for k, _ in getmembers(namespace["Config"])}
+ if any(k in config_keys for k in MetaConf.model_fields.keys()):
+ raise ConfigError(
+ "class `Config` cannot be used to configure ModelSchema. Use `Meta` instead"
+ )
+
+ if "Meta" in namespace:
+ conf_class = namespace["Meta"]
+ conf_dict = {
+ k: v for k, v in getmembers(conf_class) if not k.startswith("__")
+ }
+ try:
+ meta_conf = MetaConf.model_validate(conf_dict)
+ except ValidationError as ve:
+ raise ConfigError(str(ve)) from ve
+
+ if meta_conf and meta_conf.model:
+ existing_annotations_keys = set()
+ for base in bases:
+ existing_annotations_keys |= set(
+ getattr(base, "__annotations__", {}).keys()
+ )
+
+ meta_conf = meta_conf.model_dump(exclude_none=True)
+
+ fields = factory.convert_django_fields(**meta_conf)
+ for field, val in fields.items():
+ # do not allow field to be defined both in the class and
+ # explicitly through `Meta.fields` or implicitly through `Meta.excluded`
+ if namespace.get("__annotations__", {}).get(field):
+ raise ConfigError(
+ f"'{field}' is defined in class body and in Meta.fields or implicitly in Meta.excluded"
+ )
+ # NOTE: the check below disables the ability to declare any already existing fields on ModelSchema children
+ # class ItemSlimSchema(ModelSchema):
+ # class Meta:
+ # model = Item
+ # fields = ["id", "name"]
+ #
+ # class ItemSchema(ItemSlimSchema):
+ # class Meta(ItemSlimSchema.Meta):
+ # fields = ["type", "desc"] <-- will work with inheritting from the parent.Meta, other fields already exist
+ # on the underlying pydantic model
+ # fields = ["id", "name", "type", "desc"] <-- won't work, inheriting from parent.Meta or not
+ if field in existing_annotations_keys:
+ raise ConfigError(
+ f"Field {field} from model {meta_conf['model']} already exists in the Schema"
+ )
+ # set type
+ namespace.setdefault("__annotations__", {})[field] = val[0]
+ # and default value
+ namespace[field] = val[1]
+
cls = super().__new__(
mcs,
name,
@@ -81,45 +141,12 @@ def __new__(
namespace,
**kwargs,
)
- for base in reversed(bases):
- if (
- _is_modelschema_class_defined
- and issubclass(base, ModelSchema)
- and base == ModelSchema
- ):
- meta_conf = MetaConf.from_schema_class(name, namespace)
-
- custom_fields = []
- annotations = namespace.get("__annotations__", {})
- for attr_name, type in annotations.items():
- if attr_name.startswith("_"):
- continue
- default = namespace.get(attr_name, ...)
- custom_fields.append((attr_name, type, default))
-
- # # cls.__doc__ = namespace.get("__doc__", config.model.__doc__)
- # cls.__fields__ = {} # forcing pydantic recreate
- # # assert False, "!! cls.model_fields"
-
- # print(config.model, name, fields, exclude, "!!")
-
- model_schema = create_schema(
- meta_conf.model,
- name=name,
- fields=meta_conf.fields,
- exclude=meta_conf.exclude,
- optional_fields=meta_conf.fields_optional,
- custom_fields=custom_fields,
- base_class=cls,
- )
- model_schema.__doc__ = cls.__doc__
- return model_schema
-
return cls
class ModelSchema(Schema, metaclass=ModelSchemaMetaclass):
- pass
-
-
-_is_modelschema_class_defined = True
+ @no_type_check
+ def __new__(cls, *args, **kwargs):
+ if not getattr(getattr(cls, "Meta", {}), "model", None):
+ raise ConfigError(f"No model set for class '{cls.__name__}'")
+ return super().__new__(cls)
diff --git a/tests/test_doc_examples.py b/tests/test_doc_examples.py
new file mode 100644
index 000000000..a7359e647
--- /dev/null
+++ b/tests/test_doc_examples.py
@@ -0,0 +1,156 @@
+def test_inheritance_example():
+ """django-pydantic.md"""
+ from django.db import models
+ from pydantic import model_serializer
+
+ from ninja import ModelSchema, Schema
+
+ #
+ def _my_magic_serializer(self, handler):
+ dump = handler(self)
+ dump["magic"] = "shazam"
+ return dump
+
+ class ProjSchema(Schema):
+ # pydantic configuration
+ _my_magic_serilizer = model_serializer(mode="wrap")(_my_magic_serializer)
+ model_config = {"arbitrary_types_allowed": True}
+
+ class ProjModelSchema(ProjSchema, ModelSchema):
+ # ModelSchema specific configuration
+ pass
+
+ class ProjMeta:
+ # ModelSchema Meta defaults
+ primary_key_optional = False
+
+ #
+
+ #
+ class Item(models.Model):
+ name = models.CharField(max_length=64)
+ type = models.CharField(max_length=64)
+ desc = models.CharField(max_length=255, blank=True, null=True)
+
+ class Meta:
+ app_label = "test"
+
+ class Event(models.Model):
+ name = models.CharField(max_length=64)
+ action = models.CharField(max_length=64)
+
+ class Meta:
+ app_label = "test"
+
+ #
+
+ #
+ # All schemas will be using the configuration defined in parent Schemas
+ class ItemSlimGetSchema(ProjModelSchema):
+ class Meta(ProjMeta):
+ model = Item
+ fields = ["id", "name"]
+
+ class ItemGetSchema(ItemSlimGetSchema):
+ class Meta(ItemSlimGetSchema.Meta):
+ # inherits model, and the parents fields are already set in __annotations__
+ fields = ["type", "desc"]
+
+ class EventGetSchema(ProjModelSchema):
+ class Meta(ProjMeta):
+ model = Event
+ fields = ["id", "name"]
+
+ class ItemSummarySchema(ProjSchema):
+ model_config = {
+ # extra pydantic config
+ "title": "Item Summary"
+ }
+ name: str
+ event: EventGetSchema
+ item: ItemGetSchema
+
+ #
+ item = Item(id=1, name="test", type="amazing", desc=None)
+ event = Event(id=1, name="event", action="testing")
+ summary = ItemSummarySchema(name="summary", event=event, item=item)
+
+ assert summary.json_schema() == {
+ "$defs": {
+ "EventGetSchema": {
+ "properties": {
+ "id": {
+ "title": "ID",
+ "type": "integer",
+ },
+ "name": {
+ "maxLength": 64,
+ "title": "Name",
+ "type": "string",
+ },
+ },
+ "required": [
+ "id",
+ "name",
+ ],
+ "title": "EventGetSchema",
+ "type": "object",
+ },
+ "ItemGetSchema": {
+ "properties": {
+ "id": {
+ "title": "ID",
+ "type": "integer",
+ },
+ "name": {
+ "maxLength": 64,
+ "title": "Name",
+ "type": "string",
+ },
+ "type": {
+ "maxLength": 64,
+ "title": "Type",
+ "type": "string",
+ },
+ "desc": {
+ "anyOf": [
+ {
+ "maxLength": 255,
+ "type": "string",
+ },
+ {
+ "type": "null",
+ },
+ ],
+ "title": "Desc",
+ },
+ },
+ "required": [
+ "id",
+ "name",
+ "type",
+ ],
+ "title": "ItemGetSchema",
+ "type": "object",
+ },
+ },
+ "properties": {
+ "name": {
+ "title": "Name",
+ "type": "string",
+ },
+ "event": {
+ "$ref": "#/$defs/EventGetSchema",
+ },
+ "item": {
+ "$ref": "#/$defs/ItemGetSchema",
+ },
+ },
+ "required": [
+ "name",
+ "event",
+ "item",
+ ],
+ "title": "Item Summary",
+ "type": "object",
+ }
diff --git a/tests/test_orm_metaclass.py b/tests/test_orm_metaclass.py
index 58838a15e..8534fb13f 100644
--- a/tests/test_orm_metaclass.py
+++ b/tests/test_orm_metaclass.py
@@ -1,7 +1,10 @@
+from typing import Optional
+
import pytest
from django.db import models
+from pydantic import BaseModel
-from ninja import ModelSchema
+from ninja import ModelSchema, Schema
from ninja.errors import ConfigError
@@ -14,9 +17,9 @@ class Meta:
app_label = "tests"
class SampleSchema(ModelSchema):
- class Config:
+ class Meta:
model = User
- model_fields = ["firstname", "lastname"]
+ fields = ["firstname", "lastname"]
def hello(self):
return f"Hello({self.firstname})"
@@ -90,11 +93,67 @@ class Category(models.Model):
class Meta:
app_label = "tests"
- with pytest.raises(ConfigError):
+ with pytest.raises(ConfigError, match="Specify either `exclude` or `fields`"):
+
+ class CategorySchema1(ModelSchema):
+ class Meta:
+ model = Category
+
+ with pytest.raises(ConfigError, match="Specify either `exclude` or `fields`"):
+
+ class CategorySchema2(ModelSchema):
+ class Meta:
+ model = Category
+ exclude = ["title"]
+ fields = ["title"]
+
+ with pytest.raises(
+ ConfigError,
+ match="Use only `optional_fields`, `fields_optional` is deprecated.",
+ ):
+
+ class CategorySchema3(ModelSchema):
+ class Meta:
+ model = Category
+ fields = "__all__"
+ fields_optional = ["title"]
+ optional_fields = ["title"]
+
+ with pytest.raises(
+ ConfigError,
+ match="'title' is defined in class body and in Meta.fields or implicitly in Meta.excluded",
+ ):
+
+ class CategorySchema4(ModelSchema):
+ title: str
- class CategorySchema(ModelSchema):
class Meta:
model = Category
+ fields = "__all__"
+
+ class CategorySchema5(ModelSchema):
+ class Meta:
+ model = Category
+ fields = "__all__"
+
+ with pytest.raises(
+ ConfigError,
+ match=f"Field title from model {Category} already exists in the Schema",
+ ):
+
+ class CategorySchema6(CategorySchema5):
+ class Meta(CategorySchema5.Meta):
+ fields = ["title"]
+
+ with pytest.raises(
+ ConfigError,
+ match="class `Config` cannot be used to configure ModelSchema. Use `Meta` instead",
+ ):
+
+ class CategorySchema7(ModelSchema):
+ class Config:
+ model = Category
+ fields = "__all__"
def test_optional():
@@ -176,10 +235,375 @@ class Meta:
def test_model_schema_without_config():
+ # do not raise on creation of class
+ class NoConfigSchema(ModelSchema):
+ x: int
+
with pytest.raises(
ConfigError,
- match=r"ModelSchema class 'NoConfigSchema' requires a 'Meta' \(or a 'Config'\) subclass",
+ match=r"No model set for class 'NoConfigSchema'",
):
+ NoConfigSchema(x=1)
+
+
+def test_nondjango_model_error():
+ class NonDjangoModel:
+ field1 = models.CharField()
+ field2 = models.CharField(blank=True, null=True)
+
+ with pytest.raises(
+ ConfigError,
+ match=r"Input should be a subclass of Model \[type=is_subclass_of, input_value=.NonDjangoModel'>, input_type=type\]",
+ ):
+
+ class SomeSchema(ModelSchema):
+ class Meta:
+ model = NonDjangoModel
+ fields = "__all__"
+
+
+def test_desired_inheritance():
+ class Item(models.Model):
+ id = models.PositiveIntegerField(primary_key=True)
+ slug = models.CharField()
+
+ class Meta:
+ app_label = "tests"
+
+ class ProjectBaseSchema(Schema):
+ # add any project wide Schema/pydantic configs
+ _omissible_serialize = (
+ "serializer_func" # model_serializer(mode="wrap")(_omissible_serialize)
+ )
+
+ class ProjectBaseModelSchema(ModelSchema, ProjectBaseSchema):
+ _pydantic_config = "config"
+
+ class Meta:
+ primary_key_optional = False
+
+ class ResourceModelSchema(ProjectBaseModelSchema):
+ field1: str
+
+ class Meta(ProjectBaseModelSchema.Meta):
+ model = Item
+ fields = ["id"]
+
+ class ItemModelSchema(ResourceModelSchema):
+ field2: str
+
+ class Meta(ResourceModelSchema.Meta):
+ fields = ["slug"]
+
+ assert issubclass(ItemModelSchema, BaseModel)
+ assert ItemModelSchema.Meta.primary_key_optional is False
+
+ i = ItemModelSchema(id=1, slug="slug", field1="1", field2="2")
+
+ assert i._pydantic_config == "config"
+ assert i._omissible_serialize == "serializer_func"
+ assert i.model_dump_json() == '{"field1":"1","id":1,"field2":"2","slug":"slug"}'
+ assert i.model_json_schema() == {
+ "properties": {
+ "field1": {
+ "title": "Field1",
+ "type": "string",
+ },
+ "id": {
+ "type": "integer",
+ "title": "Id",
+ },
+ "field2": {
+ "title": "Field2",
+ "type": "string",
+ },
+ "slug": {
+ "title": "Slug",
+ "type": "string",
+ },
+ },
+ "required": [
+ "field1",
+ "id",
+ "field2",
+ "slug",
+ ],
+ "title": "ItemModelSchema",
+ "type": "object",
+ }
+
+
+def test_specific_inheritance():
+ """https://github.com/vitalik/django-ninja/issues/347"""
+
+ class Item(models.Model):
+ id = models.PositiveIntegerField
+ slug = models.CharField()
+ name = models.CharField()
+ image_path = models.CharField()
+ length_in_mn = models.PositiveIntegerField()
+ special_field_for_meal = models.CharField()
+
+ class Meta:
+ app_label = "tests"
+
+ class ItemBaseModelSchema(ModelSchema):
+ model_config = {"title": "Item Title"}
+ is_favorite: Optional[bool] = None
+
+ class Meta:
+ model = Item
+ fields = [
+ "id",
+ "slug",
+ "name",
+ "image_path",
+ ]
+
+ class ItemInBasesSchema(ItemBaseModelSchema):
+ class Config:
+ allow_inf_nan = True
+
+ class Meta(ItemBaseModelSchema.Meta):
+ fields = ["length_in_mn"]
+
+ class ItemInMealsSchema(ItemInBasesSchema):
+ model_config = {"validate_default": True}
+
+ class Meta(ItemInBasesSchema.Meta):
+ fields = ["special_field_for_meal"]
+
+ ibase = ItemBaseModelSchema(
+ id=1,
+ slug="slug",
+ name="item",
+ image_path="/images/image.png",
+ is_favorite=False,
+ )
+ item_inbases = ItemInBasesSchema(
+ id=2,
+ slug="slug",
+ name="item",
+ image_path="/images/image.png",
+ is_favorite=False,
+ length_in_mn=2,
+ )
+ item_inmeals = ItemInMealsSchema(
+ id=3,
+ slug="slug",
+ name="item",
+ image_path="/images/image.png",
+ is_favorite=False,
+ length_in_mn=2,
+ special_field_for_meal="char",
+ )
+
+ assert ibase.model_config["title"] == "Item Title"
+ assert (
+ ibase.model_dump_json()
+ == '{"is_favorite":false,"id":1,"slug":"slug","name":"item","image_path":"/images/image.png"}'
+ )
+ assert ibase.model_json_schema() == {
+ "properties": {
+ "is_favorite": {
+ "anyOf": [
+ {
+ "type": "boolean",
+ },
+ {
+ "type": "null",
+ },
+ ],
+ "default": None,
+ "title": "Is Favorite",
+ },
+ "id": {
+ "anyOf": [
+ {
+ "type": "integer",
+ },
+ {
+ "type": "null",
+ },
+ ],
+ "default": None,
+ "title": "ID",
+ },
+ "slug": {
+ "title": "Slug",
+ "type": "string",
+ },
+ "name": {
+ "title": "Name",
+ "type": "string",
+ },
+ "image_path": {
+ "title": "Image Path",
+ "type": "string",
+ },
+ },
+ "required": [
+ "slug",
+ "name",
+ "image_path",
+ ],
+ "title": "Item Title",
+ "type": "object",
+ }
+
+ assert item_inbases.model_config["allow_inf_nan"] == True # noqa: E712
+ assert (
+ item_inbases.model_dump_json()
+ == '{"is_favorite":false,"id":2,"slug":"slug","name":"item","image_path":"/images/image.png","length_in_mn":2}'
+ )
+ assert item_inbases.model_json_schema() == {
+ "properties": {
+ "is_favorite": {
+ "anyOf": [
+ {
+ "type": "boolean",
+ },
+ {
+ "type": "null",
+ },
+ ],
+ "default": None,
+ "title": "Is Favorite",
+ },
+ "id": {
+ "anyOf": [
+ {
+ "type": "integer",
+ },
+ {
+ "type": "null",
+ },
+ ],
+ "default": None,
+ "title": "ID",
+ },
+ "slug": {
+ "title": "Slug",
+ "type": "string",
+ },
+ "name": {
+ "title": "Name",
+ "type": "string",
+ },
+ "image_path": {
+ "title": "Image Path",
+ "type": "string",
+ },
+ "length_in_mn": {
+ "title": "Length In Mn",
+ "type": "integer",
+ },
+ },
+ "required": [
+ "slug",
+ "name",
+ "image_path",
+ "length_in_mn",
+ ],
+ "title": "Item Title",
+ "type": "object",
+ }
+
+ assert item_inmeals.model_config["allow_inf_nan"] == True # noqa: E712
+ assert item_inmeals.model_config["validate_default"] == True # noqa: E712
+ assert (
+ item_inmeals.model_dump_json()
+ == '{"is_favorite":false,"id":3,"slug":"slug","name":"item","image_path":"/images/image.png","length_in_mn":2,"special_field_for_meal":"char"}'
+ )
+ assert item_inmeals.model_json_schema() == {
+ "properties": {
+ "is_favorite": {
+ "anyOf": [
+ {
+ "type": "boolean",
+ },
+ {
+ "type": "null",
+ },
+ ],
+ "default": None,
+ "title": "Is Favorite",
+ },
+ "id": {
+ "anyOf": [
+ {
+ "type": "integer",
+ },
+ {
+ "type": "null",
+ },
+ ],
+ "default": None,
+ "title": "ID",
+ },
+ "slug": {
+ "title": "Slug",
+ "type": "string",
+ },
+ "name": {
+ "title": "Name",
+ "type": "string",
+ },
+ "image_path": {
+ "title": "Image Path",
+ "type": "string",
+ },
+ "length_in_mn": {
+ "title": "Length In Mn",
+ "type": "integer",
+ },
+ "special_field_for_meal": {
+ "title": "Special Field For Meal",
+ "type": "string",
+ },
+ },
+ "required": [
+ "slug",
+ "name",
+ "image_path",
+ "length_in_mn",
+ "special_field_for_meal",
+ ],
+ "title": "Item Title",
+ "type": "object",
+ }
+
+
+def test_pydantic_config_inheritance():
+ class User(models.Model):
+ firstname = models.CharField()
+ lastname = models.CharField(blank=True, null=True)
+
+ class Meta:
+ app_label = "tests"
+
+ class Grandparent(ModelSchema):
+ grandparent: str
+
+ class Config:
+ grandparent = "gpa"
+
+ class Parent(Grandparent):
+ parent: str
+
+ class Config:
+ parent = "parent"
+
+ class Child(Parent):
+ model_config = {"child": True}
+ child: str
+
+ class Meta:
+ model = User
+ fields = "__all__"
+
+ c = Child(firstname="user", lastname="name", grandparent="1", parent="2", child="3")
- class NoConfigSchema(ModelSchema):
- x: int
+ assert c.model_config["child"]
+ assert c.model_config["parent"]
+ assert c.model_config["grandparent"]
diff --git a/tests/test_schema.py b/tests/test_schema.py
index 28b2ad09a..60ce72645 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -227,3 +227,26 @@ class ValidateAssignmentSchema(Schema):
assert schema_inst.str_var == 5
except ValidationError as ve:
raise AssertionError() from ve
+
+
+def test_schema_config_inheritance():
+ class Grandparent(Schema):
+ model_config = {"grandparent": "gpa"}
+ grandparent: str
+
+ class Parent(Grandparent):
+ parent: str
+
+ class Config:
+ parent = "parent"
+
+ class Child(Parent):
+ model_config = {"child": True}
+ child: str
+
+ c = Child(grandparent="1", parent="2", child="3")
+
+ assert Grandparent.model_config["from_attributes"]
+ assert c.model_config["child"] == True # noqa: E712
+ assert c.model_config["parent"] == "parent"
+ assert c.model_config["grandparent"] == "gpa"