add examples to openapi schema, some perf improvements

This commit is contained in:
collerek
2021-04-30 16:46:41 +02:00
parent 734c33920b
commit 12c002776b
11 changed files with 194 additions and 78 deletions

View File

@ -16,6 +16,9 @@
If none of the above `ormar` (or rather pydantic) will fail during loading data from the database, If none of the above `ormar` (or rather pydantic) will fail during loading data from the database,
with missing required value for declared pydantic field. with missing required value for declared pydantic field.
* Ormar provides now a meaningful examples in openapi schema, including nested models.
The same algorithm is used to iterate related models without looks
as with `dict()` and `select/load_all`. Examples appear also in `fastapi`. [#157](https://github.com/collerek/ormar/issues/157)
## 🐛 Fixes ## 🐛 Fixes
@ -26,7 +29,9 @@
## 💬 Other ## 💬 Other
* Add connecting to the database in QuickStart in readme [#180](https://github.com/collerek/ormar/issues/180) * Add connecting to the database in QuickStart in readme [#180](https://github.com/collerek/ormar/issues/180)
* OpenAPI schema does no longer include `ormar.Model` docstring as description,
instead just model name is provided if you do not provide your own docstring.
* Some performance improvements.
# 0.10.5 # 0.10.5

View File

@ -76,7 +76,7 @@ class UndefinedType: # pragma no cover
Undefined = UndefinedType() Undefined = UndefinedType()
__version__ = "0.10.5" __version__ = "0.10.6"
__all__ = [ __all__ = [
"Integer", "Integer",
"BigInteger", "BigInteger",

View File

@ -31,6 +31,7 @@ class BaseField(FieldInfo):
def __init__(self, **kwargs: Any) -> None: def __init__(self, **kwargs: Any) -> None:
self.__type__: type = kwargs.pop("__type__", None) self.__type__: type = kwargs.pop("__type__", None)
self.__sample__: type = kwargs.pop("__sample__", None)
self.related_name = kwargs.pop("related_name", None) self.related_name = kwargs.pop("related_name", None)
self.column_type: sqlalchemy.Column = kwargs.pop("column_type", None) self.column_type: sqlalchemy.Column = kwargs.pop("column_type", None)

View File

@ -80,7 +80,7 @@ def create_dummy_model(
:rtype: pydantic.BaseModel :rtype: pydantic.BaseModel
""" """
alias = ( alias = (
"".join(choices(string.ascii_uppercase, k=2)) + uuid.uuid4().hex[:4] "".join(choices(string.ascii_uppercase, k=6)) # + uuid.uuid4().hex[:4]
).lower() ).lower()
fields = {f"{pk_field.name}": (pk_field.__type__, None)} fields = {f"{pk_field.name}": (pk_field.__type__, None)}

View File

@ -62,6 +62,7 @@ class ModelFieldFactory:
_bases: Any = (BaseField,) _bases: Any = (BaseField,)
_type: Any = None _type: Any = None
_sample: Any = None
def __new__(cls, *args: Any, **kwargs: Any) -> BaseField: # type: ignore def __new__(cls, *args: Any, **kwargs: Any) -> BaseField: # type: ignore
cls.validate(**kwargs) cls.validate(**kwargs)
@ -80,6 +81,7 @@ class ModelFieldFactory:
namespace = dict( namespace = dict(
__type__=cls._type, __type__=cls._type,
__sample__=cls._sample,
alias=kwargs.pop("name", None), alias=kwargs.pop("name", None),
name=None, name=None,
primary_key=primary_key, primary_key=primary_key,
@ -129,6 +131,7 @@ class String(ModelFieldFactory, str):
""" """
_type = str _type = str
_sample = "string"
def __new__( # type: ignore # noqa CFQ002 def __new__( # type: ignore # noqa CFQ002
cls, cls,
@ -185,6 +188,7 @@ class Integer(ModelFieldFactory, int):
""" """
_type = int _type = int
_sample = 0
def __new__( # type: ignore def __new__( # type: ignore
cls, cls,
@ -232,6 +236,7 @@ class Text(ModelFieldFactory, str):
""" """
_type = str _type = str
_sample = "text"
def __new__( # type: ignore def __new__( # type: ignore
cls, *, allow_blank: bool = True, strip_whitespace: bool = False, **kwargs: Any cls, *, allow_blank: bool = True, strip_whitespace: bool = False, **kwargs: Any
@ -267,6 +272,7 @@ class Float(ModelFieldFactory, float):
""" """
_type = float _type = float
_sample = 0.0
def __new__( # type: ignore def __new__( # type: ignore
cls, cls,
@ -316,6 +322,7 @@ else:
""" """
_type = bool _type = bool
_sample = True
@classmethod @classmethod
def get_column_type(cls, **kwargs: Any) -> Any: def get_column_type(cls, **kwargs: Any) -> Any:
@ -337,6 +344,7 @@ class DateTime(ModelFieldFactory, datetime.datetime):
""" """
_type = datetime.datetime _type = datetime.datetime
_sample = "datetime"
@classmethod @classmethod
def get_column_type(cls, **kwargs: Any) -> Any: def get_column_type(cls, **kwargs: Any) -> Any:
@ -358,6 +366,7 @@ class Date(ModelFieldFactory, datetime.date):
""" """
_type = datetime.date _type = datetime.date
_sample = "date"
@classmethod @classmethod
def get_column_type(cls, **kwargs: Any) -> Any: def get_column_type(cls, **kwargs: Any) -> Any:
@ -379,6 +388,7 @@ class Time(ModelFieldFactory, datetime.time):
""" """
_type = datetime.time _type = datetime.time
_sample = "time"
@classmethod @classmethod
def get_column_type(cls, **kwargs: Any) -> Any: def get_column_type(cls, **kwargs: Any) -> Any:
@ -400,6 +410,7 @@ class JSON(ModelFieldFactory, pydantic.Json):
""" """
_type = pydantic.Json _type = pydantic.Json
_sample = '{"json": "json"}'
@classmethod @classmethod
def get_column_type(cls, **kwargs: Any) -> Any: def get_column_type(cls, **kwargs: Any) -> Any:
@ -421,6 +432,7 @@ class LargeBinary(ModelFieldFactory, bytes):
""" """
_type = bytes _type = bytes
_sample = "bytes"
def __new__( # type: ignore # noqa CFQ002 def __new__( # type: ignore # noqa CFQ002
cls, *, max_length: int = None, **kwargs: Any cls, *, max_length: int = None, **kwargs: Any
@ -468,6 +480,7 @@ class BigInteger(Integer, int):
""" """
_type = int _type = int
_sample = 0
def __new__( # type: ignore def __new__( # type: ignore
cls, cls,
@ -515,6 +528,7 @@ class Decimal(ModelFieldFactory, decimal.Decimal):
""" """
_type = decimal.Decimal _type = decimal.Decimal
_sample = 0.0
def __new__( # type: ignore # noqa CFQ002 def __new__( # type: ignore # noqa CFQ002
cls, cls,
@ -587,6 +601,7 @@ class UUID(ModelFieldFactory, uuid.UUID):
""" """
_type = uuid.UUID _type = uuid.UUID
_sample = "uuid"
def __new__( # type: ignore # noqa CFQ002 def __new__( # type: ignore # noqa CFQ002
cls, *, uuid_format: str = "hex", **kwargs: Any cls, *, uuid_format: str = "hex", **kwargs: Any

View File

@ -3,6 +3,7 @@ import itertools
import sqlite3 import sqlite3
from typing import Any, Dict, List, TYPE_CHECKING, Tuple, Type from typing import Any, Dict, List, TYPE_CHECKING, Tuple, Type
import pydantic
from pydantic.typing import ForwardRef from pydantic.typing import ForwardRef
import ormar # noqa: I100 import ormar # noqa: I100
from ormar.models.helpers.pydantic import populate_pydantic_default_values from ormar.models.helpers.pydantic import populate_pydantic_default_values
@ -61,6 +62,12 @@ def populate_default_options_values(
else: else:
new_model.Meta.requires_ref_update = False new_model.Meta.requires_ref_update = False
new_model._json_fields = {
name
for name, field in new_model.Meta.model_fields.items()
if field.__type__ == pydantic.Json
}
class Connection(sqlite3.Connection): class Connection(sqlite3.Connection):
def __init__(self, *args: Any, **kwargs: Any) -> None: # pragma: no cover def __init__(self, *args: Any, **kwargs: Any) -> None: # pragma: no cover

View File

@ -15,6 +15,7 @@ from pydantic.main import SchemaExtraCallable
import ormar # noqa: I100, I202 import ormar # noqa: I100, I202
from ormar.fields import BaseField from ormar.fields import BaseField
from ormar.models.helpers.models import meta_field_not_set from ormar.models.helpers.models import meta_field_not_set
from ormar.queryset.utils import translate_list_to_dict
if TYPE_CHECKING: # pragma no cover if TYPE_CHECKING: # pragma no cover
from ormar import Model from ormar import Model
@ -116,12 +117,45 @@ def choices_validator(cls: Type["Model"], values: Dict[str, Any]) -> Dict[str, A
return values return values
def generate_model_example(model: Type["Model"], relation_map: Dict = None) -> Dict:
"""
Generates example to be included in schema in fastapi.
:param model: ormar.Model
:type model: Type["Model"]
:param relation_map: dict with relations to follow
:type relation_map: Optional[Dict]
:return:
:rtype: Dict[str, int]
"""
example: Dict[str, Any] = dict()
relation_map = (
relation_map
if relation_map is not None
else translate_list_to_dict(model._iterate_related_models())
)
for name, field in model.Meta.model_fields.items():
if not field.is_relation:
example[name] = field.__sample__
elif isinstance(relation_map, dict) and name in relation_map:
value = generate_model_example(
field.to, relation_map=relation_map.get(name, {})
)
new_value = [value] if field.is_multi or field.virtual else value
example[name] = new_value
return example
def construct_modify_schema_function(fields_with_choices: List) -> SchemaExtraCallable: def construct_modify_schema_function(fields_with_choices: List) -> SchemaExtraCallable:
""" """
Modifies the schema to include fields with choices validator. Modifies the schema to include fields with choices validator.
Those fields will be displayed in schema as Enum types with available choices Those fields will be displayed in schema as Enum types with available choices
values listed next to them. values listed next to them.
Note that schema extra has to be a function, otherwise it's called to soon
before all the relations are expanded.
:param fields_with_choices: list of fields with choices validation :param fields_with_choices: list of fields with choices validation
:type fields_with_choices: List :type fields_with_choices: List
:return: callable that will be run by pydantic to modify the schema :return: callable that will be run by pydantic to modify the schema
@ -133,6 +167,28 @@ def construct_modify_schema_function(fields_with_choices: List) -> SchemaExtraCa
if field_id in fields_with_choices: if field_id in fields_with_choices:
prop["enum"] = list(model.Meta.model_fields[field_id].choices) prop["enum"] = list(model.Meta.model_fields[field_id].choices)
prop["description"] = prop.get("description", "") + "An enumeration." prop["description"] = prop.get("description", "") + "An enumeration."
schema["example"] = generate_model_example(model=model)
if "Main base class of ormar Model." in schema.get("description", ""):
schema["description"] = f"{model.__name__}"
return staticmethod(schema_extra) # type: ignore
def construct_schema_function_without_choices() -> SchemaExtraCallable:
"""
Modifies model example and description if needed.
Note that schema extra has to be a function, otherwise it's called to soon
before all the relations are expanded.
:return: callable that will be run by pydantic to modify the schema
:rtype: Callable
"""
def schema_extra(schema: Dict[str, Any], model: Type["Model"]) -> None:
schema["example"] = generate_model_example(model=model)
if "Main base class of ormar Model." in schema.get("description", ""):
schema["description"] = f"{model.__name__}"
return staticmethod(schema_extra) # type: ignore return staticmethod(schema_extra) # type: ignore
@ -162,3 +218,5 @@ def populate_choices_validators(model: Type["Model"]) -> None: # noqa CCR001
model.Config.schema_extra = construct_modify_schema_function( model.Config.schema_extra = construct_modify_schema_function(
fields_with_choices=fields_with_choices fields_with_choices=fields_with_choices
) )
else:
model.Config.schema_extra = construct_schema_function_without_choices()

View File

@ -94,6 +94,7 @@ def add_cached_properties(new_model: Type["Model"]) -> None:
new_model._related_fields = None new_model._related_fields = None
new_model._pydantic_fields = {name for name in new_model.__fields__} new_model._pydantic_fields = {name for name in new_model.__fields__}
new_model._choices_fields = set() new_model._choices_fields = set()
new_model._json_fields = set()
def add_property_fields(new_model: Type["Model"], attrs: Dict) -> None: # noqa: CCR001 def add_property_fields(new_model: Type["Model"], attrs: Dict) -> None: # noqa: CCR001

View File

@ -48,7 +48,7 @@ class RelationMixin:
:return: list of related fields :return: list of related fields
:rtype: List :rtype: List
""" """
if isinstance(cls._related_fields, List): if cls._related_fields is not None:
return cls._related_fields return cls._related_fields
related_fields = [] related_fields = []
@ -66,7 +66,7 @@ class RelationMixin:
:return: set of related through fields names :return: set of related through fields names
:rtype: Set :rtype: Set
""" """
if isinstance(cls._through_names, Set): if cls._through_names is not None:
return cls._through_names return cls._through_names
related_names = set() related_names = set()
@ -86,7 +86,7 @@ class RelationMixin:
:return: set of related fields names :return: set of related fields names
:rtype: Set :rtype: Set
""" """
if isinstance(cls._related_names, Set): if cls._related_names is not None:
return cls._related_names return cls._related_names
related_names = set() related_names = set()

View File

@ -1,5 +1,4 @@
import sys import sys
import uuid
from typing import ( from typing import (
AbstractSet, AbstractSet,
Any, Any,
@ -87,6 +86,7 @@ class NewBaseModel(pydantic.BaseModel, ModelTableProxy, metaclass=ModelMetaclass
_choices_fields: Optional[Set] _choices_fields: Optional[Set]
_pydantic_fields: Set _pydantic_fields: Set
_quick_access_fields: Set _quick_access_fields: Set
_json_fields: Set
Meta: ModelMeta Meta: ModelMeta
# noinspection PyMissingConstructor # noinspection PyMissingConstructor
@ -124,60 +124,12 @@ class NewBaseModel(pydantic.BaseModel, ModelTableProxy, metaclass=ModelMetaclass
:type kwargs: Any :type kwargs: Any
""" """
self._verify_model_can_be_initialized() self._verify_model_can_be_initialized()
object.__setattr__(self, "_orm_id", uuid.uuid4().hex) self._initialize_internal_attributes()
object.__setattr__(self, "_orm_saved", False)
object.__setattr__(self, "_pk_column", None)
object.__setattr__(
self,
"_orm",
RelationsManager(
related_fields=self.extract_related_fields(), owner=cast("Model", self),
),
)
pk_only = kwargs.pop("__pk_only__", False) pk_only = kwargs.pop("__pk_only__", False)
object.__setattr__(self, "__pk_only__", pk_only) object.__setattr__(self, "__pk_only__", pk_only)
excluded: Set[str] = kwargs.pop("__excluded__", set()) new_kwargs, through_tmp_dict = self._process_kwargs(kwargs)
if "pk" in kwargs:
kwargs[self.Meta.pkname] = kwargs.pop("pk")
# build the models to set them and validate but don't register
# also remove property fields values from validation
try:
new_kwargs: Dict[str, Any] = {
k: self._convert_json(
k,
self.Meta.model_fields[k].expand_relationship(
v, self, to_register=False,
)
if k in self.Meta.model_fields
else (
v
if k in self.__fields__
# some random key will raise KeyError
else self.__fields__["_Q*DHPQ(JAS*((JA)###*(&"]
),
"dumps",
)
for k, v in kwargs.items()
if k not in object.__getattribute__(self, "Meta").property_fields
}
except KeyError as e:
raise ModelError(
f"Unknown field '{e.args[0]}' for model {self.get_name(lower=False)}"
)
# explicitly set None to excluded fields
# as pydantic populates them with default if set
for field_to_nullify in excluded:
new_kwargs[field_to_nullify] = None
# extract through fields
through_tmp_dict = dict()
for field_name in self.extract_through_names():
through_tmp_dict[field_name] = new_kwargs.pop(field_name, None)
values, fields_set, validation_error = pydantic.validate_model( values, fields_set, validation_error = pydantic.validate_model(
self, new_kwargs # type: ignore self, new_kwargs # type: ignore
@ -190,10 +142,10 @@ class NewBaseModel(pydantic.BaseModel, ModelTableProxy, metaclass=ModelMetaclass
# add back through fields # add back through fields
new_kwargs.update(through_tmp_dict) new_kwargs.update(through_tmp_dict)
model_fields = object.__getattribute__(self, "Meta").model_fields
# register the columns models after initialization # register the columns models after initialization
for related in self.extract_related_names().union(self.extract_through_names()): for related in self.extract_related_names().union(self.extract_through_names()):
self.Meta.model_fields[related].expand_relationship( model_fields[related].expand_relationship(
new_kwargs.get(related), self, to_register=True, new_kwargs.get(related), self, to_register=True,
) )
@ -314,15 +266,93 @@ class NewBaseModel(pydantic.BaseModel, ModelTableProxy, metaclass=ModelMetaclass
:return: None :return: None
:rtype: None :rtype: None
""" """
if self.Meta.abstract: if object.__getattribute__(self, "Meta").abstract:
raise ModelError(f"You cannot initialize abstract model {self.get_name()}") raise ModelError(f"You cannot initialize abstract model {self.get_name()}")
if self.Meta.requires_ref_update: if object.__getattribute__(self, "Meta").requires_ref_update:
raise ModelError( raise ModelError(
f"Model {self.get_name()} has not updated " f"Model {self.get_name()} has not updated "
f"ForwardRefs. \nBefore using the model you " f"ForwardRefs. \nBefore using the model you "
f"need to call update_forward_refs()." f"need to call update_forward_refs()."
) )
def _process_kwargs(self, kwargs: Dict) -> Tuple[Dict, Dict]:
"""
Initializes nested models.
Removes property_fields
Checks if field is in the model fields or pydatnic fields.
Nullifies fields that should be excluded.
Extracts through models from kwargs into temporary dict.
:param kwargs: passed to init keyword arguments
:type kwargs: Dict
:return: modified kwargs
:rtype: Tuple[Dict, Dict]
"""
meta = object.__getattribute__(self, "Meta")
property_fields = meta.property_fields
model_fields = meta.model_fields
pydantic_fields = object.__getattribute__(self, "__fields__")
# remove property fields
for prop_filed in property_fields:
kwargs.pop(prop_filed, None)
excluded: Set[str] = kwargs.pop("__excluded__", set())
if "pk" in kwargs:
kwargs[meta.pkname] = kwargs.pop("pk")
# extract through fields
through_tmp_dict = dict()
for field_name in self.extract_through_names():
through_tmp_dict[field_name] = kwargs.pop(field_name, None)
try:
new_kwargs: Dict[str, Any] = {
k: self._convert_json(
k,
model_fields[k].expand_relationship(v, self, to_register=False,)
if k in model_fields
else (
v
if k in pydantic_fields
else model_fields["HAP&*YA^)*GW^&QT6567q56gGG%$%"]
),
"dumps",
)
for k, v in kwargs.items()
}
except KeyError as e:
raise ModelError(
f"Unknown field '{e.args[0]}' for model {self.get_name(lower=False)}"
)
# explicitly set None to excluded fields
# as pydantic populates them with default if set
for field_to_nullify in excluded:
new_kwargs[field_to_nullify] = None
return new_kwargs, through_tmp_dict
def _initialize_internal_attributes(self) -> None:
"""
Initializes internal attributes during __init__()
:rtype: None
"""
# object.__setattr__(self, "_orm_id", uuid.uuid4().hex)
object.__setattr__(self, "_orm_saved", False)
object.__setattr__(self, "_pk_column", None)
object.__setattr__(
self,
"_orm",
RelationsManager(
related_fields=self.extract_related_fields(), owner=cast("Model", self),
),
)
def _extract_related_model_instead_of_field( def _extract_related_model_instead_of_field(
self, item: str self, item: str
) -> Optional[Union["Model", Sequence["Model"]]]: ) -> Optional[Union["Model", Sequence["Model"]]]:
@ -363,8 +393,8 @@ class NewBaseModel(pydantic.BaseModel, ModelTableProxy, metaclass=ModelMetaclass
:rtype: bool :rtype: bool
""" """
return ( return (
self._orm_id == other._orm_id # self._orm_id == other._orm_id
or (self.pk == other.pk and self.pk is not None) (self.pk == other.pk and self.pk is not None)
or ( or (
(self.pk is None and other.pk is None) (self.pk is None and other.pk is None)
and { and {
@ -748,7 +778,7 @@ class NewBaseModel(pydantic.BaseModel, ModelTableProxy, metaclass=ModelMetaclass
:return: converted value if needed, else original value :return: converted value if needed, else original value
:rtype: Any :rtype: Any
""" """
if not self._is_conversion_to_json_needed(column_name): if column_name not in object.__getattribute__(self, "_json_fields"):
return value return value
condition = ( condition = (
@ -765,20 +795,6 @@ class NewBaseModel(pydantic.BaseModel, ModelTableProxy, metaclass=ModelMetaclass
pass pass
return value.decode("utf-8") if isinstance(value, bytes) else value return value.decode("utf-8") if isinstance(value, bytes) else value
def _is_conversion_to_json_needed(self, column_name: str) -> bool:
"""
Checks if given column name is related to JSON field.
:param column_name: name of the field
:type column_name: str
:return: result of the check
:rtype: bool
"""
return (
column_name in self.Meta.model_fields
and self.Meta.model_fields[column_name].__type__ == pydantic.Json
)
def _extract_own_model_fields(self) -> Dict: def _extract_own_model_fields(self) -> Dict:
""" """
Returns a dictionary with field names and values for fields that are not Returns a dictionary with field names and values for fields that are not

View File

@ -1,6 +1,7 @@
from typing import List from typing import List
import databases import databases
import pydantic
import pytest import pytest
import sqlalchemy import sqlalchemy
from fastapi import FastAPI from fastapi import FastAPI
@ -124,6 +125,18 @@ def test_schema_modification():
x.get("type") == "array" for x in schema["properties"]["categories"]["anyOf"] x.get("type") == "array" for x in schema["properties"]["categories"]["anyOf"]
) )
assert schema["properties"]["categories"]["title"] == "Categories" assert schema["properties"]["categories"]["title"] == "Categories"
assert schema["example"] == {
"id": 0,
"name": "string",
"categories": [{"id": 0, "name": "string"}],
}
schema = Category.schema()
assert schema["example"] == {
"id": 0,
"name": "string",
"items": [{"id": 0, "name": "string"}],
}
def test_schema_gen(): def test_schema_gen():