From dedc90329ccfa570a4483fa80fa9b8bfd10b0069 Mon Sep 17 00:00:00 2001 From: collerek Date: Wed, 17 Feb 2021 18:19:48 +0100 Subject: [PATCH 1/2] fix multiple pkonly models with same name in openapi schema --- docs/releases.md | 6 + ormar/__init__.py | 2 +- ormar/fields/foreign_key.py | 9 +- ormar/models/metaclass.py | 154 +++++++++--------- ...est_docs_with_multiple_relations_to_one.py | 72 ++++++++ 5 files changed, 163 insertions(+), 80 deletions(-) create mode 100644 tests/test_docs_with_multiple_relations_to_one.py diff --git a/docs/releases.md b/docs/releases.md index 4b5a94e72..30e6fcbf3 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1,3 +1,9 @@ +# 0.9.4 + +## Fixes +* Fix `fastapi` OpenAPI schema generation for automatic docs when multiple models refer to the same related one + + # 0.9.3 ## Fixes diff --git a/ormar/__init__.py b/ormar/__init__.py index 4ef50ddec..b2c70202a 100644 --- a/ormar/__init__.py +++ b/ormar/__init__.py @@ -68,7 +68,7 @@ def __repr__(self) -> str: Undefined = UndefinedType() -__version__ = "0.9.3" +__version__ = "0.9.4" __all__ = [ "Integer", "BigInteger", diff --git a/ormar/fields/foreign_key.py b/ormar/fields/foreign_key.py index 30602ae48..35a7b2aa8 100644 --- a/ormar/fields/foreign_key.py +++ b/ormar/fields/foreign_key.py @@ -1,6 +1,8 @@ +import string import sys import uuid from dataclasses import dataclass +from random import choices from typing import Any, List, Optional, TYPE_CHECKING, Tuple, Type, Union import sqlalchemy @@ -67,9 +69,14 @@ def create_dummy_model( :return: constructed dummy model :rtype: pydantic.BaseModel """ + alias = ( + "".join(choices(string.ascii_uppercase, k=2)) + uuid.uuid4().hex[:4] + ).lower() fields = {f"{pk_field.name}": (pk_field.__type__, None)} dummy_model = create_model( - f"PkOnly{base_model.get_name(lower=False)}", **fields # type: ignore + f"PkOnly{base_model.get_name(lower=False)}{alias}", + __module__=base_model.__module__, + **fields, # type: ignore ) return dummy_model diff --git a/ormar/models/metaclass.py b/ormar/models/metaclass.py index 0fc6f5d5a..4134fd51c 100644 --- a/ormar/models/metaclass.py +++ b/ormar/models/metaclass.py @@ -46,6 +46,7 @@ from ormar import Model CONFIG_KEY = "Config" +PARSED_FIELDS_KEY = "__parsed_fields__" class ModelMeta: @@ -141,83 +142,6 @@ def register_signals(new_model: Type["Model"]) -> None: # noqa: CCR001 new_model.Meta.signals = signals -class ModelMetaclass(pydantic.main.ModelMetaclass): - def __new__( # type: ignore # noqa: CCR001 - mcs: "ModelMetaclass", name: str, bases: Any, attrs: dict - ) -> "ModelMetaclass": - """ - Metaclass used by ormar Models that performs configuration - and build of ormar Models. - - - Sets pydantic configuration. - Extract model_fields and convert them to pydantic FieldInfo, - updates class namespace. - - Extracts settings and fields from parent classes. - Fetches methods decorated with @property_field decorator - to expose them later in dict(). - - Construct parent pydantic Metaclass/ Model. - - If class has Meta class declared (so actual ormar Models) it also: - - * populate sqlalchemy columns, pkname and tables from model_fields - * register reverse relationships on related models - * registers all relations in alias manager that populates table_prefixes - * exposes alias manager on each Model - * creates QuerySet for each model and exposes it on a class - - :param name: name of current class - :type name: str - :param bases: base classes - :type bases: Tuple - :param attrs: class namespace - :type attrs: Dict - """ - attrs["Config"] = get_pydantic_base_orm_config() - attrs["__name__"] = name - attrs, model_fields = extract_annotations_and_default_vals(attrs) - for base in reversed(bases): - mod = base.__module__ - if mod.startswith("ormar.models.") or mod.startswith("pydantic."): - continue - attrs, model_fields = extract_from_parents_definition( - base_class=base, curr_class=mcs, attrs=attrs, model_fields=model_fields - ) - new_model = super().__new__( # type: ignore - mcs, name, bases, attrs - ) - - add_cached_properties(new_model) - - if hasattr(new_model, "Meta"): - populate_default_options_values(new_model, model_fields) - check_required_meta_parameters(new_model) - add_property_fields(new_model, attrs) - register_signals(new_model=new_model) - populate_choices_validators(new_model) - - if not new_model.Meta.abstract: - new_model = populate_meta_tablename_columns_and_pk(name, new_model) - populate_meta_sqlalchemy_table_if_required(new_model.Meta) - expand_reverse_relationships(new_model) - for field in new_model.Meta.model_fields.values(): - register_relation_in_alias_manager(field=field) - - if new_model.Meta.pkname not in attrs["__annotations__"]: - field_name = new_model.Meta.pkname - attrs["__annotations__"][field_name] = Optional[int] # type: ignore - attrs[field_name] = None - new_model.__fields__[field_name] = get_pydantic_field( - field_name=field_name, model=new_model - ) - new_model.Meta.alias_manager = alias_manager - new_model.objects = QuerySet(new_model) - - return new_model - - def verify_constraint_names( base_class: "Model", model_fields: Dict, parent_value: List ) -> None: @@ -539,4 +463,78 @@ def update_attrs_and_fields( return updated_model_fields -PARSED_FIELDS_KEY = "__parsed_fields__" +class ModelMetaclass(pydantic.main.ModelMetaclass): + def __new__( # type: ignore # noqa: CCR001 + mcs: "ModelMetaclass", name: str, bases: Any, attrs: dict + ) -> "ModelMetaclass": + """ + Metaclass used by ormar Models that performs configuration + and build of ormar Models. + + + Sets pydantic configuration. + Extract model_fields and convert them to pydantic FieldInfo, + updates class namespace. + + Extracts settings and fields from parent classes. + Fetches methods decorated with @property_field decorator + to expose them later in dict(). + + Construct parent pydantic Metaclass/ Model. + + If class has Meta class declared (so actual ormar Models) it also: + + * populate sqlalchemy columns, pkname and tables from model_fields + * register reverse relationships on related models + * registers all relations in alias manager that populates table_prefixes + * exposes alias manager on each Model + * creates QuerySet for each model and exposes it on a class + + :param name: name of current class + :type name: str + :param bases: base classes + :type bases: Tuple + :param attrs: class namespace + :type attrs: Dict + """ + attrs["Config"] = get_pydantic_base_orm_config() + attrs["__name__"] = name + attrs, model_fields = extract_annotations_and_default_vals(attrs) + for base in reversed(bases): + mod = base.__module__ + if mod.startswith("ormar.models.") or mod.startswith("pydantic."): + continue + attrs, model_fields = extract_from_parents_definition( + base_class=base, curr_class=mcs, attrs=attrs, model_fields=model_fields + ) + new_model = super().__new__( # type: ignore + mcs, name, bases, attrs + ) + + add_cached_properties(new_model) + + if hasattr(new_model, "Meta"): + populate_default_options_values(new_model, model_fields) + check_required_meta_parameters(new_model) + add_property_fields(new_model, attrs) + register_signals(new_model=new_model) + populate_choices_validators(new_model) + + if not new_model.Meta.abstract: + new_model = populate_meta_tablename_columns_and_pk(name, new_model) + populate_meta_sqlalchemy_table_if_required(new_model.Meta) + expand_reverse_relationships(new_model) + for field in new_model.Meta.model_fields.values(): + register_relation_in_alias_manager(field=field) + + if new_model.Meta.pkname not in attrs["__annotations__"]: + field_name = new_model.Meta.pkname + attrs["__annotations__"][field_name] = Optional[int] # type: ignore + attrs[field_name] = None + new_model.__fields__[field_name] = get_pydantic_field( + field_name=field_name, model=new_model + ) + new_model.Meta.alias_manager = alias_manager + new_model.objects = QuerySet(new_model) + + return new_model diff --git a/tests/test_docs_with_multiple_relations_to_one.py b/tests/test_docs_with_multiple_relations_to_one.py new file mode 100644 index 000000000..f3747b735 --- /dev/null +++ b/tests/test_docs_with_multiple_relations_to_one.py @@ -0,0 +1,72 @@ +from typing import Optional +from uuid import UUID, uuid4 + +import databases +import sqlalchemy +from fastapi import FastAPI +from starlette.testclient import TestClient + +import ormar + +app = FastAPI() +DATABASE_URL = "sqlite:///db.sqlite" +database = databases.Database(DATABASE_URL) +metadata = sqlalchemy.MetaData() + + +class BaseMeta(ormar.ModelMeta): + metadata = metadata + database = database + + +class CA(ormar.Model): + class Meta(BaseMeta): + tablename = "cas" + + id: UUID = ormar.UUID(primary_key=True, default=uuid4) + ca_name: str = ormar.Text(default="") + + +class CB1(ormar.Model): + class Meta(BaseMeta): + tablename = "cb1s" + + id: UUID = ormar.UUID(primary_key=True, default=uuid4) + cb1_name: str = ormar.Text(default="") + ca1: Optional[CA] = ormar.ForeignKey(CA, nullable=True) + + +class CB2(ormar.Model): + class Meta(BaseMeta): + tablename = "cb2s" + + id: UUID = ormar.UUID(primary_key=True, default=uuid4) + cb2_name: str = ormar.Text(default="") + ca2: Optional[CA] = ormar.ForeignKey(CA, nullable=True) + + +@app.get("/ca", response_model=CA) +async def get_ca(): + return None + + +@app.get("/cb1", response_model=CB1) +async def get_cb1(): + return None + + +@app.get("/cb2", response_model=CB2) +async def get_cb2(): + return None + + +def test_all_endpoints(): + client = TestClient(app) + with client as client: + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + schema = response.json() + components = schema["components"]["schemas"] + assert all(x in components for x in ["CA", "CB1", "CB2"]) + pk_onlys = [x for x in list(components.keys()) if x.startswith("PkOnly")] + assert len(pk_onlys) == 2 From 0dbe424c94d26cc49636aeb7e5d43b5697c59bf4 Mon Sep 17 00:00:00 2001 From: collerek Date: Wed, 17 Feb 2021 18:24:26 +0100 Subject: [PATCH 2/2] fix coverage --- tests/test_docs_with_multiple_relations_to_one.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_docs_with_multiple_relations_to_one.py b/tests/test_docs_with_multiple_relations_to_one.py index f3747b735..6baffa6e9 100644 --- a/tests/test_docs_with_multiple_relations_to_one.py +++ b/tests/test_docs_with_multiple_relations_to_one.py @@ -46,17 +46,17 @@ class Meta(BaseMeta): @app.get("/ca", response_model=CA) -async def get_ca(): +async def get_ca(): # pragma: no cover return None @app.get("/cb1", response_model=CB1) -async def get_cb1(): +async def get_cb1(): # pragma: no cover return None @app.get("/cb2", response_model=CB2) -async def get_cb2(): +async def get_cb2(): # pragma: no cover return None