'Imperatively map both entity and value object inside that entity to one table

Let's say I have an entity which is mapped to a postgresql table in sqlalchemy.

@dataclass
class User:
    id: int
    email: str
    password: str
    country: str
    city: str
    is_admin: bool = False

    def __eq__(self, other) -> bool:
        return self.id == other.id

user_table = sa.Table(
    'user',
    mapper_registry.metadata,
    sa.Column('id', UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()'), nullable=False),
    sa.Column('email', sa.String(100), nullable=False, unique=True),
    sa.Column('password', sa.String(128), nullable=False),
    sa.Column('country', sa.String(50)),
    sa.Column('city', sa.String(50)),
    sa.Column('is_admin', sa.Boolean(), server_default=sa.text('false'), nullable=False),
)


user_mapper = mapper_registry.map_imperatively(user_model.User, user_table)

That's not hard. Now I want to move address information into a separate value object inside User entity, but still map it to one table on postgresql side.

@dataclass(frozen=True)
class UserAddress:
    country: str | None = None
    city: str | None = None


@dataclass
class User:
    id: int
    email: str
    password: str
    address: UserAddress = UserAddress()
    is_admin: bool = False

    def __eq__(self, other) -> bool:
        return self.id == other.id

user_table = sa.Table(
    'user',
    mapper_registry.metadata,
    sa.Column('id', UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()'), nullable=False),
    sa.Column('email', sa.String(100), nullable=False, unique=True),
    sa.Column('password', sa.String(128), nullable=False),
    sa.Column('country', sa.String(50)),
    sa.Column('city', sa.String(50)),
    sa.Column('is_admin', sa.Boolean(), server_default=sa.text('false'), nullable=False),
)


user_mapper = ???

What is the best way to do this?

UPD: I have found composites in SQLAlchemy documentation, which kinda works:

user_mapper = mapper_registry.map_imperatively(user_model.User, user_table, properties={
    'info': composite(user_model.UserInfo, user_table.c.name, user_table.c.last_name, user_table.c.birthdate, user_table.c.phone)
})

BUT it requires UserInfo to have __composite_values__ method, which I want to avoid. I don't want UserInfo class to know anything about db-specific stuff and implement any methods for it.

Is there any other way?



Solution 1:[1]

Since there are no other answers, I am going to show what I ended up doing. Can't say that I am happy with this solution, but it works. Basically I am defining models at domain layer and then injecting method(s) needed to use composites at infrastructure layer. Something like this:

domain/user/user_model.py

@dataclass(frozen=True)
class UserAddress:
    country: str | None = None
    city: str | None = None


@dataclass
class User:
    id: int
    email: str
    password: str
    address: UserAddress = UserAddress()
    is_admin: bool = False

    def __eq__(self, other) -> bool:
        return self.id == other.id

infra/postgresql/user/user_mappings.py

from app.domain.user import user_model

user_table = sa.Table(
    'user',
    mapper_registry.metadata,
    sa.Column('id', UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()'), nullable=False),
    sa.Column('email', sa.String(100), nullable=False, unique=True),
    sa.Column('password', sa.String(128), nullable=False),
    sa.Column('country', sa.String(50)),
    sa.Column('city', sa.String(50)),
    sa.Column('is_admin', sa.Boolean(), server_default=sa.text('false'), nullable=False),
)

def userinfo_comp_values(self):
    return self.name, self.last_name, self.birthdate, self.phone
user_model.UserInfo.__composite_values__ = userinfo_comp_values 

user_mapper = mapper_registry.map_imperatively(user_model.User, user_table, properties={
    'info': composite(user_model.UserInfo, user_table.c.name, user_table.c.last_name, user_table.c.birthdate, user_table.c.phone)
})


Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 estw272