From ded50538e5d5efd07998451a897724228e913b30 Mon Sep 17 00:00:00 2001 From: collerek Date: Tue, 16 Feb 2021 09:49:41 +0100 Subject: [PATCH 1/4] Update issue templates --- .github/ISSUE_TEMPLATE/bug_report.md | 36 +++++++++++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 20 +++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..5f9c639 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,36 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +(Note: this should be a complete and concise piece of code that allows reproduction of an issue) + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Versions (please complete the following information):** + - Database backend used (mysql/sqlite/postgress) + - Python version + - `ormar` version + - `pydantic` version + - if applicable `fastapi` version + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..11fc491 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. From edafbc20fc598866fad5213a7a29fbaf360eb4e0 Mon Sep 17 00:00:00 2001 From: collerek Date: Tue, 16 Feb 2021 09:58:20 +0100 Subject: [PATCH 2/4] Create config.yml --- .github/ISSUE_TEMPLATE/config.yml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/config.yml diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..73e70e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,7 @@ +contact_links: + - name: I have a question ❓ + url: https://github.com/collerek/ormar/discussions + about: If you have any question about the usage of ormar, please open a discussion first. + - name: I want a new feature 🆕 + url: https://github.com/collerek/ormar/discussions + about: If you would like to request or make a change/enhancement that is not trivial, please open a discussion first. From dedc90329ccfa570a4483fa80fa9b8bfd10b0069 Mon Sep 17 00:00:00 2001 From: collerek Date: Wed, 17 Feb 2021 18:19:48 +0100 Subject: [PATCH 3/4] 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 4b5a94e..30e6fcb 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 4ef50dd..b2c7020 100644 --- a/ormar/__init__.py +++ b/ormar/__init__.py @@ -68,7 +68,7 @@ class UndefinedType: # pragma no cover 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 30602ae..35a7b2a 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 0fc6f5d..4134fd5 100644 --- a/ormar/models/metaclass.py +++ b/ormar/models/metaclass.py @@ -46,6 +46,7 @@ if TYPE_CHECKING: # pragma no cover 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 0000000..f3747b7 --- /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 4/4] 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 f3747b7..6baffa6 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 CB2(ormar.Model): @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