Skip to content

11. DTO SQL

A DTO can inherit from a SQL entity without creating another SQLAlchemy mapping. UserSummary then inherits from UserRead and adds a method; its UUID field retains its type and internal fields remain inaccessible.

Run from the repository root:

uv run --extra data-sql python examples/core/11_dto_sql.py

Source

"""Derive a DTO from a SQL entity and extend it with ordinary inheritance.

Run: uv run --extra data-sql python examples/core/11_dto_sql.py

The UUID and visibility of inherited fields are preserved. Creating DTO
classes does not register additional SQLAlchemy mappings. DTO instances are
data containers; use the source entity when persisting to the database.
"""

from datetime import UTC, datetime
from typing import assert_type
from uuid import UUID, uuid4

from sqlmodel import Field as SQLField

from fastsprout.core import Field, InternalField, ReadDTO, ReadField
from fastsprout.data.backend.sql import SQLEntity
from fastsprout.data.backend.sql.entity import SQL_ENTITY_REGISTRY


class DefaultSQLEntity(SQLEntity[UUID], table=False):
    id: Field[UUID] = SQLField(default_factory=uuid4, primary_key=True)
    last_login: InternalField[datetime | None] = InternalField[datetime | None](
        default=None
    )


class User(DefaultSQLEntity):
    email: Field[str]
    password_hash: InternalField[str]
    created_at: ReadField[datetime] = ReadField(
        default_factory=lambda: datetime.now(UTC)
    )


MAPPERS_BEFORE_DTOS = set(SQL_ENTITY_REGISTRY.mappers)


class UserRead(ReadDTO, User): ...


class UserSummary(UserRead):
    def label(self) -> str:
        return f"{self.id}: {self.email}"


def display(user: UserRead) -> str:
    assert_type(user.id, UUID)
    assert_type(user.created_at, datetime)
    return user.email


def main() -> None:
    read = UserRead(email="[email protected]")
    summary = UserSummary(email="[email protected]")

    assert isinstance(read.id, UUID)
    assert isinstance(summary.id, UUID)
    assert read.id != summary.id
    assert isinstance(summary, User)
    assert set(SQL_ENTITY_REGISTRY.mappers) == MAPPERS_BEFORE_DTOS
    assert "_sa_instance_state" not in vars(summary)

    print(display(read))
    print(display(summary))
    print(summary.label().endswith(": [email protected]"))

    # Both checkers reject direct access, including on the subclass:
    # _ = summary.last_login
    # _ = UserSummary.password_hash

    for name in ("last_login", "password_hash"):
        try:
            getattr(summary, name)
        except AttributeError as error:
            print(error)
        else:
            raise AssertionError(f"Field {name!r} should be inaccessible")


if __name__ == "__main__":
    main()

Result

The script prints two email addresses, confirms that the inherited label includes the expected email, and checks forbidden fields:

[email protected]
[email protected]
True
Field 'last_login' not accessible in DTO (operations: ['read'])
Field 'password_hash' not accessible in DTO (operations: ['read'])