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,
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
@ -26,7 +29,9 @@
## 💬 Other
* 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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@ import itertools
import sqlite3
from typing import Any, Dict, List, TYPE_CHECKING, Tuple, Type
import pydantic
from pydantic.typing import ForwardRef
import ormar # noqa: I100
from ormar.models.helpers.pydantic import populate_pydantic_default_values
@ -61,6 +62,12 @@ def populate_default_options_values(
else:
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):
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
from ormar.fields import BaseField
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
from ormar import Model
@ -116,12 +117,45 @@ def choices_validator(cls: Type["Model"], values: Dict[str, Any]) -> Dict[str, A
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:
"""
Modifies the schema to include fields with choices validator.
Those fields will be displayed in schema as Enum types with available choices
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
:type fields_with_choices: List
: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:
prop["enum"] = list(model.Meta.model_fields[field_id].choices)
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
@ -162,3 +218,5 @@ def populate_choices_validators(model: Type["Model"]) -> None: # noqa CCR001
model.Config.schema_extra = construct_modify_schema_function(
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._pydantic_fields = {name for name in new_model.__fields__}
new_model._choices_fields = set()
new_model._json_fields = set()
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
:rtype: List
"""
if isinstance(cls._related_fields, List):
if cls._related_fields is not None:
return cls._related_fields
related_fields = []
@ -66,7 +66,7 @@ class RelationMixin:
:return: set of related through fields names
:rtype: Set
"""
if isinstance(cls._through_names, Set):
if cls._through_names is not None:
return cls._through_names
related_names = set()
@ -86,7 +86,7 @@ class RelationMixin:
:return: set of related fields names
:rtype: Set
"""
if isinstance(cls._related_names, Set):
if cls._related_names is not None:
return cls._related_names
related_names = set()

View File

@ -1,5 +1,4 @@
import sys
import uuid
from typing import (
AbstractSet,
Any,
@ -87,6 +86,7 @@ class NewBaseModel(pydantic.BaseModel, ModelTableProxy, metaclass=ModelMetaclass
_choices_fields: Optional[Set]
_pydantic_fields: Set
_quick_access_fields: Set
_json_fields: Set
Meta: ModelMeta
# noinspection PyMissingConstructor
@ -124,60 +124,12 @@ class NewBaseModel(pydantic.BaseModel, ModelTableProxy, metaclass=ModelMetaclass
:type kwargs: Any
"""
self._verify_model_can_be_initialized()
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),
),
)
self._initialize_internal_attributes()
pk_only = kwargs.pop("__pk_only__", False)
object.__setattr__(self, "__pk_only__", pk_only)
excluded: Set[str] = kwargs.pop("__excluded__", set())
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)
new_kwargs, through_tmp_dict = self._process_kwargs(kwargs)
values, fields_set, validation_error = pydantic.validate_model(
self, new_kwargs # type: ignore
@ -190,10 +142,10 @@ class NewBaseModel(pydantic.BaseModel, ModelTableProxy, metaclass=ModelMetaclass
# add back through fields
new_kwargs.update(through_tmp_dict)
model_fields = object.__getattribute__(self, "Meta").model_fields
# register the columns models after initialization
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,
)
@ -314,15 +266,93 @@ class NewBaseModel(pydantic.BaseModel, ModelTableProxy, metaclass=ModelMetaclass
:return: None
:rtype: None
"""
if self.Meta.abstract:
if object.__getattribute__(self, "Meta").abstract:
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(
f"Model {self.get_name()} has not updated "
f"ForwardRefs. \nBefore using the model you "
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(
self, item: str
) -> Optional[Union["Model", Sequence["Model"]]]:
@ -363,8 +393,8 @@ class NewBaseModel(pydantic.BaseModel, ModelTableProxy, metaclass=ModelMetaclass
:rtype: bool
"""
return (
self._orm_id == other._orm_id
or (self.pk == other.pk and self.pk is not None)
# self._orm_id == other._orm_id
(self.pk == other.pk and self.pk is not None)
or (
(self.pk is None and other.pk is None)
and {
@ -748,7 +778,7 @@ class NewBaseModel(pydantic.BaseModel, ModelTableProxy, metaclass=ModelMetaclass
:return: converted value if needed, else original value
:rtype: Any
"""
if not self._is_conversion_to_json_needed(column_name):
if column_name not in object.__getattribute__(self, "_json_fields"):
return value
condition = (
@ -765,20 +795,6 @@ class NewBaseModel(pydantic.BaseModel, ModelTableProxy, metaclass=ModelMetaclass
pass
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:
"""
Returns a dictionary with field names and values for fields that are not

View File

@ -1,6 +1,7 @@
from typing import List
import databases
import pydantic
import pytest
import sqlalchemy
from fastapi import FastAPI
@ -124,6 +125,18 @@ def test_schema_modification():
x.get("type") == "array" for x in schema["properties"]["categories"]["anyOf"]
)
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():