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"