Skip to content

Commit

Permalink
Merge pull request #105 from collerek/fastapi_docs
Browse files Browse the repository at this point in the history
fix multiple pkonly models with same name in openapi schema
  • Loading branch information
collerek authored Feb 17, 2021
2 parents edafbc2 + 0dbe424 commit 88baf9e
Show file tree
Hide file tree
Showing 5 changed files with 163 additions and 80 deletions.
6 changes: 6 additions & 0 deletions docs/releases.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion ormar/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ def __repr__(self) -> str:

Undefined = UndefinedType()

__version__ = "0.9.3"
__version__ = "0.9.4"
__all__ = [
"Integer",
"BigInteger",
Expand Down
9 changes: 8 additions & 1 deletion ormar/fields/foreign_key.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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

Expand Down
154 changes: 76 additions & 78 deletions ormar/models/metaclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
from ormar import Model

CONFIG_KEY = "Config"
PARSED_FIELDS_KEY = "__parsed_fields__"


class ModelMeta:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
72 changes: 72 additions & 0 deletions tests/test_docs_with_multiple_relations_to_one.py
Original file line number Diff line number Diff line change
@@ -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(): # pragma: no cover
return None


@app.get("/cb1", response_model=CB1)
async def get_cb1(): # pragma: no cover
return None


@app.get("/cb2", response_model=CB2)
async def get_cb2(): # pragma: no cover
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

0 comments on commit 88baf9e

Please sign in to comment.