Search code examples
pythonsqlalchemyorm

How to return a default object instead of `None` for an optional relationship in SQLAlchemy?


I have a users table and a roles table, such that a User optionally has a single Role:

class User(Base):
    id: Mapped[int] = mapped_column(primary_key=True)

    role: Mapped[Optional["Role"]] = relationship(back_populates="user")
    # ...


class Role(Base):
    id: Mapped[int] = mapped_column(primary_key=True)
    user_id: Mapped[int] = mapped_column(ForeignKey("user.id"))

    user: Mapped[Optional["User"]] = relationship(back_populates="role")
    # ...

Now in code I would like that User.role always return a Role. If a specific User doesn't have a Role, it should just return a new default one: Role().

How can I replace User.role with some kind of getter function that does this?


A somewhat obvious solution would be:

class User(Base):
    # ...
    @property
    def role_or_new():
        return self.role or Role()

However, I kind of want to override the default attribute name, role.


There are some similarly named existing questions, but I think they're all subtly different:


Solution

  • Following @metatoaser's hint, I renamed the relationship from the user and created a property with the original name for compatibility:

    class User(Base):
        id: Mapped[int] = mapped_column(primary_key=True)
    
        role_row: Mapped[Optional["Role"]] = relationship(back_populates="user")
        # ...
    
        @property
        def role() -> Role:
            if self.role_row is None:
                self.role_row = Role()
                # Consider using `make_transient(self.role_row)` here
            return self.role_row
    
    
    class Role(Base):
        id: Mapped[int] = mapped_column(primary_key=True)
        user_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
    
        user: Mapped[Optional["User"]] = relationship(back_populates="role_row")
        # ...
    

    Mind that the back_populates= of the Role model is now different too.

    I opted for a public property, role_row, such an advanced user could still choose to use the original relationship, which would be discouraged with a name like _role.

    Final note: the newly created Role() will be queued in the session by default! E.g.:

    user = session.get(User, 1)
    if user.role.access:
        # ...
    
    user.email = new_email
    session.commit()
    

    ...will trigger the creation of a new Role in the database! This is because every created object by default corresponds to a database row.
    If you want to prevent this default object from always appearing in the database on the next commit, you could add a call to make_transient in the custom getter.