Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add subclass support and discriminated unions #106

Closed
Fatal1ty opened this issue May 4, 2023 · 0 comments · Fixed by #111
Closed

Add subclass support and discriminated unions #106

Fatal1ty opened this issue May 4, 2023 · 0 comments · Fixed by #111
Assignees
Labels
enhancement New feature or request

Comments

@Fatal1ty
Copy link
Owner

Fatal1ty commented May 4, 2023

Is your feature request related to a problem? Please describe.

There are situations when you have multiple subclasses that are distinguishable by a specific field like "type" or "code":

@dataclass
class Event(DataClassDictMixin):
    ...

@dataclass
class Event1(Event):
    event_type: Literal[1] = 1
    ...

@dataclass
class Event2(Event):
    event_type: Literal[2] = 2
    ...

Currently, the only way to deserialise such classes is to use Union type or write your own code that would select a class depending on some value. Such approaches can be found in the related issue:

We can make it so that it can be done without writing boilerplate code by users. It will also be very useful to get Event2 instance from Event as follows:

event2 = Event.from_json('{"event_type": 2, ...}')

Using discriminated unions instead of simple Union[A, B] we can also get a huge performance boost.

Describe the solution you'd like

There is such a thing as discriminated unions. This is what we need. We can introduce a new entity called "Discriminator" which will be used to describe the class selection rules.

@dataclass
class Discriminator:
    field: Optional[str] = None
    include_subclasses: bool = False

Using discriminator with subclasses

@dataclass
class Event(DataClassDictMixin):  # we have a common parent class
    ...

@dataclass
class Event1(Event):
    event_type: Literal[1] = 1
    ...

@dataclass
class Event2(Event):
    event_type: Literal[2] = 2
    ...

@dataclass
class Message(DataClassDictMixin):
    # using the parent class here
    event: Annotated[Event, Discriminator(field="event_type", include_subclasses=True)]

event2_msg = Message.from_dict({"event": {"event_type": 2, ...}}))  # <- Event2 will be used for event

@dataclass
class Event3(Event):  # a new event declared after the Message
    event_type: Literal[3] = 3
    ...

event3_msg = Message.from_dict({"event": {"event_type": 3, ...}}))  # <- Event3 will be used for event

Using discriminator with unions

Deserialization of Unions will be much faster because there will be no traversal of all classes and an attempt to deserialize each of them.

@dataclass
class Event1(DataClassDictMixin):
    event_type: Literal[1] = 1
    ...

@dataclass
class Event2(DataClassDictMixin):
    event_type: Literal[2] = 2
    ...

@dataclass
class Message(DataClassDictMixin):
    # no common parent classes here, using Union
    event: Annotated[Union[Event1, Event2], Discriminator(field="event_type")]

event2_msg = Message.from_dict({"event": {"event_type": 2, ...}})  # <- Event2 will be used for event

Using discriminator inside a parent class config

@dataclass
class Event(DataClassDictMixin):
    class Config:
        # using discriminator in a parent class
        discriminator = Discriminator(field="event_type", include_subclasses=True)

@dataclass
class Event1(Event):
    event_type: Literal[1] = 1
    ...

@dataclass
class Event2(Event):
    event_type: Literal[2] = 2
    ...


event2 = Event.from_dict({"event_type": 2, ...})  # Event2 instance will be returned


@dataclass
class Message(DataClassDictMixin):
    event: Event  # using the parent class here

event2_msg = Message.from_dict({"event": {"event_type": 2, ...}})  # <- Event2 will be used for event


@dataclass
class Event3(Event):  # a new event declared after the Message
    event_type: Literal[3] = 3
    ...

event3 = Event.from_dict({"event_type": 3, ...})  # <- Event3 instance will be returned
event3_msg = Message.from_dict({"event": {"event_type": 3, ...}})  # <- Event3 will be used for event

Using discriminator with unions and subclasses

@dataclass
class Event(DataClassDictMixin):
    ...

@dataclass
class ClientEvent(Event):
    ...

@dataclass
class ServerEvent(Event):
    ...

@dataclass
class SystemEvent(Event):
    ...


@dataclass
class ClientEvent1(ClientEvent):
    code: Literal["client_event_1"] = "client_event_1"
    ...

@dataclass
class ServerEvent1(ServerEvent):
    code: Literal["server_event_1"] = "server_event_1"
    ...

@dataclass
class SystemEvent1(SystemEvent):
    code: Literal["system_event_1"] = "system_event_1"
    ...


@dataclass
class Message(DataClassDictMixin):
    event: Annotated[Union[ClientEvent, ServerEvent], Discriminator(field="code", include_subclasses=True)]


# ServerEvent1 will be used for event:
server_event1_msg = Message.from_dict({"event": {"code": "server_event_1", ...}}))
# Wrong event code will produce an error:
Message.from_dict({"event": {"code": "system_event_1", ...}})

Additional context

For a discriminator value we could use the following class attributes:

  • without annotations: code = 42
  • annotated as ClassVar: code: ClassVar[int] = 42
  • annotated as Final: code: Final[int] = 42
  • annotated as Literal: code: Literal[42] = 42
  • Enum value: code: ClientEvent = ClientEvent.FOO
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

Successfully merging a pull request may close this issue.

1 participant