tortoise/tortoise-orm

naming of originating model in model_json_schema with pydantic_queryset_creator

markus-96 opened this issue · 0 comments

Describe the bug
If I use pydantic_queryset_creator, I get a Pydantic RootModel of type List. Normally, if I call model_json_schema of a RootModel, the originating model name is referenced in $defs. But if I use pydantic_queryset_creator, the model name is always leaf in the produced json schema. Additionaly, if I specify a name when calling pydantic_queryset_creator, this name will be used for the type of object that is in the array and also for the array itself, that makes no sense for me. It is a bit hard to explain for me, so I provided some example code with additional comments that I hope will be enough to understand what I meen. If not, please ask!

I also rewrote pydantic_queryset_creator to match my desired behaviour, but maybe I completely get the function wrong. Maybe it also breaks something with pydantic_model_creator, that is for to complex for me to fully understand.

Example code with additional comments

import json
from typing import Type, Optional, List

from pydantic import BaseModel, RootModel, create_model, Field
from tortoise import Model
from tortoise.contrib.pydantic import pydantic_model_creator, pydantic_queryset_creator, PydanticListModel
from tortoise.contrib.pydantic.creator import _cleandoc
from tortoise.fields import IntField

this is for explaining what I was expecting from pydantic_queryset_creator:

class Pet(BaseModel):  # <-- normal BaseModel
    id: int


class Pets(RootModel[list[Pet]]):  # <-- normal RootModel
    ...


# RootModel, like it is generated in pydantic_queryset_creator
PetsGenerated = create_model(f"{Pet.__name__}_list", __base__=RootModel, root=(list[Pet], Field(default_factory=list)))

and how pydantic_queryset_creator actually behaves:

class PetTortoise(Model):  # <-- basic Tortoise Model
    id = IntField(pk=True)
    ...


PetTortoisePydantic = pydantic_model_creator(PetTortoise)  # <-- this works fine
PetTortoiseListPydantic = pydantic_queryset_creator(PetTortoise)  # <-- array with "leafs" as items
PetTortoiseListPydanticWithName = pydantic_queryset_creator(PetTortoise, name=PetTortoise.__name__)  # <-- array with "PetTortoise" as title and "PetTortoise" as items

and my slightly modified version of pydantic_queryset_creator:

def pydantic_queryset_creator_patched(
    cls: "Type[Model]",
    *,
    name=None,
    submodel_name=None,  # <-- introduced option to explicitly name the items of the array
    exclude: tuple[str, ...] = (),
    include: tuple[str, ...] = (),
    computed: tuple[str, ...] = (),
    allow_cycles: Optional[bool] = None,
    sort_alphabetically: Optional[bool] = None,
) -> Type[PydanticListModel]:
    _submodel_name = submodel_name or cls.__name__  # <-- if not set, use the name of the originating model as name for the submodel
    submodel = pydantic_model_creator(
        cls,
        exclude=exclude,
        include=include,
        computed=computed,
        allow_cycles=allow_cycles,
        sort_alphabetically=sort_alphabetically,
        name=_submodel_name,
    )
    lname = name or f"{cls.__name__}_list"

    # Creating Pydantic class for the properties generated before
    model = create_model(
        lname,
        __base__=PydanticListModel,
        root=(List[submodel], Field(default_factory=list)),  # type: ignore
    )
    # Copy the Model docstring over
    model.__doc__ = _cleandoc(cls)
    # The title of the model to hide the hash postfix
    model.model_config["title"] = name or f"{submodel.model_config['title']}_list"
    model.model_config["submodel"] = submodel  # type: ignore
    return model


PetTortoiseListPydanticPatched = pydantic_queryset_creator_patched(PetTortoise)  # <-- array named "PetTortoise_list" with items "PetTortoise"


if __name__ == "__main__":
    print(json.dumps(Pet.model_json_schema(), indent=2))
    """results in:
    {
      "properties": {
        "id": {
          "title": "Id",
          "type": "integer"
        }
      },
      "required": [
        "id"
      ],
      "title": "Pet",  # <-- title is Pet
      "type": "object"
    }
    """

a manually created RootModel, where the title is passed in via RootModel.__name__

    print(json.dumps(Pets.model_json_schema(), indent=2))
    """results in:
    {
      "$defs": {
        "Pet": {  # <-- the items are of type Pet
          "properties": {
            "id": {
              "title": "Id",
              "type": "integer"
            }
          },
          "required": [
            "id"
          ],
          "title": "Pet",
          "type": "object"
        }
      },
      "items": {
        "$ref": "#/$defs/Pet"
      },
      "title": "Pets",  # <-- we are dealing with Pets here
      "type": "array"
    }
    """

If we generate a RootModel like in pydantic_queryset_creator, it works perfectly fine:

    print(json.dumps(PetsGenerated.model_json_schema(), indent=2))
    """results in:
    {
      "$defs": {
        "Pet": {
          "properties": {
            "id": {
              "title": "Id",
              "type": "integer"
            }
          },
          "required": [
            "id"
          ],
          "title": "Pet",
          "type": "object"
        }
      },
      "items": {
        "$ref": "#/$defs/Pet"
      },
      "title": "Pet_list",  # <-- generated title like in pydantic_queryset_creator
      "type": "array"
    }
    """

pydantic_model_creator works perfectly fine:

    print(json.dumps(PetTortoisePydantic.model_json_schema(), indent=2))
    """results in:
    {
      "additionalProperties": false,
      "properties": {
        "id": {
          "maximum": 2147483647,
          "minimum": -2147483648,
          "title": "Id",
          "type": "integer"
        }
      },
      "required": [
        "id"
      ],
      "title": "PetTortoise",  # <-- title is correct
      "type": "object"
    }
    """

what is leaf?

    print(json.dumps(PetTortoiseListPydantic.model_json_schema(), indent=2))
    """results in:
    {
      "$defs": {
        "leaf": {                # <-- what is leaf?
          "additionalProperties": false,
          "properties": {
            "id": {
              "maximum": 2147483647,
              "minimum": -2147483648,
              "title": "Id",
              "type": "integer"
            }
          },
          "required": [
            "id"
          ],
          "title": "PetTortoise",  # <-- this is correct
          "type": "object"
        }
      },
      "items": {
        "$ref": "#/$defs/leaf"
      },
      "title": "PetTortoise_list",
      "type": "array"
    }
    """

title of items and array are the same:

    print(json.dumps(PetTortoiseListPydanticWithName.model_json_schema(), indent=2))
    """results in:
    {
      "$defs": {
        "PetTortoise": {  # <-- this is perfect
          "additionalProperties": false,
          "properties": {
            "id": {
              "maximum": 2147483647,
              "minimum": -2147483648,
              "title": "Id",
              "type": "integer"
            }
          },
          "required": [
            "id"
          ],
          "title": "PetTortoise",  # <-- also this
          "type": "object"
        }
      },
      "items": {
        "$ref": "#/$defs/PetTortoise"
      },
      "title": "PetTortoise",  # <-- why is this the same as the items?
      "type": "array"
    }
    """

desired behaviour:

    print(json.dumps(PetTortoiseListPydanticPatched.model_json_schema(), indent=2))
    """results in:
    {
      "$defs": {
        "PetTortoise": {  # <-- :)
          "additionalProperties": false,
          "properties": {
            "id": {
              "maximum": 2147483647,
              "minimum": -2147483648,
              "title": "Id",
              "type": "integer"
            }
          },
          "required": [
            "id"
          ],
          "title": "PetTortoise",  # <-- :)
          "type": "object"
        }
      },
      "items": {
        "$ref": "#/$defs/PetTortoise"
      },
      "title": "PetTortoise_list",  # <-- :)
      "type": "array"
    }
    """

Additional context
I want to dynamically build OpenAPI Spec with the definitions provided by model_json_schema of the Pydantic BaseModels generated by pydantic_model_creator and pydantic_queryset_creator. For that, I need to remove all $defs that are referenced in a $ref and store them in #/components/schemas for example. Patching every $ref to point to #/components/schemas can be easily done by providing a ref_template for model_json_schema. But it is not great that every $def of a pydantic list model has "leaf" as a key.