Search code examples
sqlalchemyfastapipydanticsqlmodel

'__post_init__' or dynamically create new fields of SQLModel after initiation


My objective is to create a new field based on another field after a Request is posted to FastAPI. My attempt was:

import functools
from datetime import date, datetime

from slugify import slugify
from sqlmodel import Field, SQLModel


class ProjectBase(SQLModel):
    is_active: bool = Field(default=True, nullable=False)
    name: str = Field(..., nullable=False)

    publish_date: date = Field(default_factory=datetime.now().date, nullable=False)
    
    # # post_init
    repr_name: str = Field(
        default_factory=functools.partial(slugify, name, separator='_'),
        description="The project represented name, it must contain no whitespace, each word is separated by an underscore and it is slugified using the python-slugify library.",
        nullable=False)   

I have also tried __post_init__ but I think SQLModel does not have such a mechanism, it belongs to dataclasses within pydantic.

My desired output would be something like if a Request like the below was POST-ed:

request = {
    'is_active': true,
    'name': 'hello world bye',
    'publish_date': '2023-01-01'
}

Then, the following Response is gotten and inserted into the database:

response = {
    'is_active': true,
    'name': 'hello world bye',
    'repr_name': 'hello_world_bye', # output of slugify(`name`, separator='_')
    'publish_date': '2023-01-01'
}

Solution

  • Thankfully sqlmodel.SQLModel inherits from pydantic.BaseModel. (And their metaclasses are also related.)

    This is yet another job for custom validators. We just need a default sentinel value to check against, if no explicit value is provided by the user. I would just make the field type a union of str and None and make None the default, but then always ensure a str ends up as the value via the validator.

    Here is an example:

    from datetime import date, datetime
    from typing import Any, Optional
    
    from pydantic import validator
    from slugify import slugify
    from sqlmodel import Field, SQLModel
    
    
    class ProjectBase(SQLModel):
        name: str = Field(..., nullable=False)
        publish_date: date = Field(default_factory=datetime.now().date, nullable=False)
        repr_name: Optional[str] = Field(default=None, nullable=False)
    
        @validator("repr_name", always=True)
        def slugify_name(cls, v: Optional[str], values: dict[str, Any]) -> str:
            if v is None:
                return slugify(values["name"], separator="_")
            return slugify(v, separator="_")
    
    
    print(ProjectBase(name="foo bar baz").json(indent=2))
    print(ProjectBase(name="spam", repr_name="Eggs and Bacon").json(indent=2))
    

    Output:

    {
      "name": "foo bar baz",
      "publish_date": "2023-02-11",
      "repr_name": "foo_bar_baz"
    }
    
    {
      "name": "spam",
      "publish_date": "2023-02-11",
      "repr_name": "eggs_and_bacon"
    }
    

    The always=True is important to trigger validation, when no value was explicitly provided.

    The order of the fields is also important because the values dictionary in the validator method will only contain previously validated fields and field validation occurs in the order the fields were defined (see docs link above), so since name comes before repr_name, it will be validated and its value therefore present in the dictionary, when repr_name validation is triggered.

    Important: Validation will not work, if the validators belong to a table=True model. To get around this, define a base model with the fields you need validated and inherit from that in your table=True model; then parse data through the parent model before instantiating the table model with it.

    This bit seems pretty limiting, so I would not rule out the possibility that this will change in the future, at least to a certain extent. But in the current state of SQLModel that is what we have.