Skip to content

DTOs

What are DTOs?

DTO stands for "Data Transfer Object". They are the filter through which data is accepted into, and output from the application.

DTO Factory

starlite-saqlalchemy includes dto.factory() which automatically creates pydantic models from SQLAlchemy 2.0 ORM models.

Creating a SQLAlchemy ORM model

If you are new to SQLAlchemy, I cannot recommend their docs enough. Start at the beginning, and follow along until you are comfortable. I won't even try to compete with the quality and depth of information that can be found there - a credit to everyone who has contributed to that project over the years.

Configuring generated DTOs

dto.Purpose

The dto.Purpose enum tells the factory if the purpose of the DTO is to parse data submitted by the client for updating or "writing" to a resource, or if it is to serialize data to be transmitted back to, or "read" by the client.

The DTO objects generated by the factory may differ due to the intended purpose of the DTO. Here's an example of setting the DTO purpose:

from starlite_saqlalchemy import dto

from domain.users import User


ReadDTO = dto.factory("UserReadDTO", model=User, purpose=dto.Purpose.READ)
WriteDTO = dto.factory("UserWriteDTO", model=User, purpose=dto.Purpose.WRITE)

dto.Attrib

The dto.Attrib object is a container for configuring how the generated DTO object should reflect the SQLAlchemy model field.

Define this in the SQLAlchemy info parameter to mapped_column(), for example, mapped_column(info={"dto": dto.Attrib()}).

The DTO Attrib object has two values that can be set:

  • dto.Attrib.mark: a value of the enum dto.Mark.
  • dto.Attrib.pydantic_field: return value of the pydantic Field function that will be used to construct the pydantic model field for the model attribute.

dto.Mark

We use the info parameter to mapped_column() to guide dto.factory().

The dto.Mark enumeration is used to indicate on the whether properties on the SQLAlchemy model should always be private, or read-only.

For example:

from datetime import datetime

from sqlalchemy.orm import mapped_column
from starlite_saqlalchemy import dto, orm


class User(orm.Base):
    name: str
    password_hash: str = mapped_column(info={"dto": dto.Attrib(mark=dto.Mark.SKIP)})
    updated_at: datetime = mapped_column(
        info={"dto": dto.Attrib(mark=dto.Mark.READ_ONLY)}
    )


ReadDTO = dto.factory("UserReadDTO", model=User, purpose=dto.Purpose.READ)
WriteDTO = dto.factory("UserWriteDTO", model=User, purpose=dto.Purpose.WRITE)

Both ReadDTO and WriteDTO are pydantic models that have a name attribute.

Neither ReadDTO or WriteDTO have a password_hash attribute - this is the side effect of marking the column with dto.Mark.SKIP. Skipped columns are never included in any generated DTO model, meaning that they are unable to be read or modified by the client.

ReadDTO has an updated_at field, while WriteDTO does not. This is the side effect of marking the column with dto.Mark.READ_ONLY - these fields will only be included in DTOs generated for dto.Purpose.READ and make sense for fields that have internally generated values.

The following class is pretty much the same as orm.Base - the bundled SQLAlchemy base class that comes with starlite-saqlalchemy.

from datetime import datetime
from uuid import UUID, uuid4

from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

from starlite_saqlalchemy import dto


class Base(DeclarativeBase):
    id: Mapped[UUID] = mapped_column(
        default=uuid4,
        primary_key=True,
        info={"dto": dto.Attrib(mark=dto.Mark.READ_ONLY)},
    )
    """Primary key column."""
    created: Mapped[datetime] = mapped_column(
        default=datetime.now, info={"dto": dto.Attrib(mark=dto.Mark.READ_ONLY)}
    )
    """Date/time of instance creation."""
    updated: Mapped[datetime] = mapped_column(
        default=datetime.now, info={"dto": dto.Attrib(mark=dto.Mark.READ_ONLY)}
    )

Notice that all these fields are marked as dto.Mark.READ_ONLY. This means that they are unable to be modified by clients, even if they include values for them in the payloads to POST/PUT/PATCH routes.

You can inherit from orm.Base to create your SQLAlchemy models, but you don't have to. You can choose to subclass orm.Base or roll your own base class altogether. dto.factory() will still work as advertised.