3

I am trying to use Enums in SQLAlchemy 2.0 with mapped_column. So far I have the following code (taken from another question):

from sqlalchemy.dialects.postgresql import ENUM as pgEnum
import enum

class CampaignStatus(str, enum.Enum):
    activated = "activated"
    deactivated = "deactivated"

CampaignStatusType: pgEnum = pgEnum(
    CampaignStatus,
    name="campaignstatus",
    create_constraint=True,
    metadata=Base.metadata,
    validate_strings=True,
)

class Campaign(Base):
    __tablename__ = "campaign"

    id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
    created_at: Mapped[dt.datetime] = mapped_column(default=dt.datetime.now)
    status: Mapped[CampaignStatusType] = mapped_column(nullable=False)

However, that gives the following error upon the construction of the Campaign class itself.

Traceback (most recent call last):
  File "<stdin>", line 27, in <module>
    class Campaign(Base):
...
AttributeError: 'ENUM' object has no attribute '__mro__'

Any hint about how to make this work?

The response from ENUM type in SQLAlchemy with PostgreSQL does not apply as I am using version 2 of SQLAlchemy and those answers did not use mapped_column or Mapped types. Also, removing str from CampaignStatus does not help.

metatoaster
  • 17,419
  • 5
  • 55
  • 66
Javier Guzmán
  • 1,014
  • 2
  • 13
  • 27
  • Moreover `class CampaignStatus(str, enum.Enum): ...` will not work because this is mixing two different classes with different metaclass - you shouldn't try inherit from two completely unrelated classes unless you know exactly what you are doing. Just do `class CampaignStatus(enum.Enum): ...` – metatoaster May 17 '23 at 05:34
  • Thanks but I have just added str because on another SO question someone mentioned that made it work for him, which is not my case. Even removing str does not help – Javier Guzmán May 17 '23 at 20:05
  • I had to set up your environment manually (as you didn't provide a complete `Traceback`); it took time to deduce that `status: Mapped[CampaignStatusType] = mapped_column(nullable=False)` being the cause - changing this to the previous method of defining columns `status = sqlalchemy.Column(CampaignStatusType, nullable=False)` (e.g. from the linked duplicate) still works. In any case with the clarification I reopened the question. – metatoaster May 18 '23 at 00:03

1 Answers1

3

The crux of the issue relating to __mro__ causing the AttributeError is that CampaignStatusType is not a class, but rather an instance variable of type sqlalchemy.dialects.postgresql.ENUM (using pyright may verify this - given that it complains about Mapped[CampaignStatusType] being an "Illegal type annotation: variable not allowed unless it is a type alias"). As a test, replacing the type annotation for status with Mapped[CampaignStatus] does resolve the issue (and pyright reports no errors), but that does not hook the column type to the enum with postgresql dialect that is desired.

So the only way around this while using the dialect specific enum type is to use the non-annotated construct:

    status = mapped_column(CampaignStatusType, nullable=False)

However, if type annotation is still desired, i.e. whatever being Mapped must be a type, and that sqlalchemy.dialects.postgresql.ENUM (which was imported as pgEnum) is the underlying type for the instance CampaignStatusType, it may be thought that the following might be a solution

    # don't do this erroneous example despite it does run
    status: Mapped[sqlalchemy.dialects.postgresql.ENUM] = mapped_column(
        CampaignStatusType,
        nullable=False,
    )

While it works, it does NOT actually reflect what will be represented by the data, so DO NOT actually do that. Moreover, it only works because the type annotation is ignored when the specific column type is passed, so putting anything in there will work while having an invalid type.

Now, given that SQLAlchemy is now 2.0 (as the question explicitly want this newer version), perhaps reviewing the documentation and see now native enums should be handled now.

Adapting the examples in the documentation, the following MVCE may now be derived, using all the intended keyword arguments that was passed to the PostgreSQL dialect specific ENUM type passed generic sqlalchemy.Enum instead (aside from metadata=Base.metadata as that's completely superfluous):

from typing import Literal
from typing import get_args
from sqlalchemy import Enum
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column

CampaignStatus = Literal["activated", "deactivated"]

class Base(DeclarativeBase):
    pass

class Campaign(Base):
    __tablename__ = "campaign"
    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
    status: Mapped[CampaignStatus] = mapped_column(Enum(
        *get_args(CampaignStatus),
        name="campaignstatus",
        create_constraint=True,
        validate_strings=True,
    ))

Note the use of typing.get_args on CampaignStatus and splat it to the Enum here as opposed to what the official examples have done in repeating themselves. Now to include the usage:

from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

def main():
    engine = create_engine('postgresql://postgres@localhost/postgres')
    Base.metadata.create_all(engine)
    Session = sessionmaker(bind=engine)
    session = Session()
    session.add(Campaign(status='activated'))
    session.add(Campaign(status='deactivated'))
    session.commit()

    s = 'some_unvalidated_string'
    try:
        session.add(Campaign(status=s))
        session.commit()
    except Exception:
        print("failed to insert with %r" % s)

if __name__ == '__main__':
    main()

The above will produce failed to insert with 'some_unvalidated_string' as the output, showing that unvalidated strings will not be inserted, while validated strings that are mapped to some enum are inserted without issues. Moreover, pyright will not produce errors (though honestly, this is not necessarily a good metric because type hinting in Python is still fairly half-baked, as pyright did not detect the erroneous example as an error in the very beginning no matter what went inside Mapped, but I digress).

Viewing the newly created entities using psql

postgres=# select * from campaign;
 id |   status    
----+-------------
  1 | activated
  2 | deactivated
(2 rows)
postgres=# \dt campaign;
                                Table "public.campaign"
 Column |      Type      | Collation | Nullable |               Default                
--------+----------------+-----------+----------+--------------------------------------
 id     | integer        |           | not null | nextval('campaign_id_seq'::regclass)
 status | campaignstatus |           | not null | 
Indexes:
    "campaign_pkey" PRIMARY KEY, btree (id)

postgres=# \dT+ campaignstatus;
                                             List of data types
 Schema |      Name      | Internal name  | Size |  Elements   |  Owner   | Access privileges | Description 
--------+----------------+----------------+------+-------------+----------+-------------------+-------------
 public | campaignstatus | campaignstatus | 4    | activated  +| postgres |                   | 
        |                |                |      | deactivated |          |                   | 
(1 row)

The enum of course cannot be dropped without dropping the campaign table:

postgres=# drop type campaignstatus;
ERROR:  cannot drop type campaignstatus because other objects depend on it
DETAIL:  column status of table campaign depends on type campaignstatus
HINT:  Use DROP ... CASCADE to drop the dependent objects too.

So the enum more or less behaves as expected despite only using generic SQLAlchemy types, without needing dialect specific imports.

metatoaster
  • 17,419
  • 5
  • 55
  • 66
  • Sorry I have been traveling and I have not been able to test this thoroughly but so far it builds ok. In a couple of days I will try to test the execution and come back to let you know if it is ok – Javier Guzmán May 21 '23 at 14:58
  • 1
    Sorry for the late reply but I did not forget about your help. I have managed to find time to test this properly and indeed it seems to work. Again thank you for the help and great explanation. Have a great day! – Javier Guzmán May 27 '23 at 13:42