Repository: Fatal1ty/mashumaro

Issue:
Allow discrimination based on a key that is not part of the model
**Is your feature request related to a problem? Please describe.**
I'am using mashumaro as (de)serialization backend of my [AST library](https://github.com/mishamsk/pyoak/)

What this means is that I (de)serialize huge (millions of nodes) nested dataclasses with fields usually having a general type that may have tens and sometimes hunderds subclasses. E.g. borrowing from the example in my own docs:
```python
@dataclass
class Expr(ASTNode):
    """Base for all expressions"""
    pass

@dataclass
class Literal(Expr):
    """Base for all literals"""
    value: Any

@dataclass
class NumberLiteral(Literal):
    value: int

@dataclass
class NoneLiteral(Literal):
    value: None = field(default=None, init=False)

@dataclass
class BinExpr(Expr):
    """Base for all binary expressions"""
    left: Expr
    right: Expr
```

`Expr` can (or rather has in real life) hundreds of subclasses.

Thus it is impractical to let mashumaro try discriminating the actual type by iteratively trying every subclass during deserialization. Also, it will be incorrect in some cases because two subclasses may have the same structure, but I need exactly the same type as it was written during serialization.

I am already handling this via my own extension: [see code](https://github.com/mishamsk/pyoak/blob/f030f71d7ced2ab04a77b7f2497599fce5c1abad/src/pyoak/serialize.py#L111)

My custom implementation adds `__type` key during serialization which is read during deserialization and then dropped. If it is not available, it falls back to the default logic.

However, this way I can't use some of the other mashumaro features, such as annotations in _serialize/_deserialize, and I have to resort to "dirty" class state to pass additional options (what has been discussed in #100)

**Describe the solution you'd like**
I suggest adding an option to the discriminator feature to allow using keys that are not part of the data class itself.

**Describe alternatives you've considered**
See above my current implementation


Comments:

Patch:
diff --git a/mashumaro/core/meta/code/builder.py b/mashumaro/core/meta/code/builder.py
index 7ba9d0e..00c16db 100644
--- a/mashumaro/core/meta/code/builder.py
+++ b/mashumaro/core/meta/code/builder.py
@@ -330,6 +330,7 @@ class CodeBuilder:
                     # prevent RecursionError
                     field=discr.field,
                     include_subtypes=discr.include_subtypes,
+                    variant_tagger_fn=discr.variant_tagger_fn,
                 )
                 self.add_type_modules(self.cls)
                 method = SubtypeUnpackerBuilder(discr).build(
diff --git a/mashumaro/core/meta/types/unpack.py b/mashumaro/core/meta/types/unpack.py
index 0b5bacb..7cc7a1c 100644
--- a/mashumaro/core/meta/types/unpack.py
+++ b/mashumaro/core/meta/types/unpack.py
@@ -317,6 +317,13 @@ class DiscriminatedUnionUnpackerBuilder(AbstractUnpackerBuilder):
         variant_method_call = self._get_variant_method_call(
             variant_method_name, spec
         )
+        if discriminator.variant_tagger_fn:
+            spec.builder.ensure_object_imported(
+                discriminator.variant_tagger_fn, "variant_tagger_fn"
+            )
+            variant_tagger_expr = "variant_tagger_fn(variant)"
+        else:
+            variant_tagger_expr = f"variant.__dict__['{discriminator.field}']"
 
         if spec.builder.dialect:
             spec.builder.ensure_object_imported(
@@ -345,8 +352,7 @@ class DiscriminatedUnionUnpackerBuilder(AbstractUnpackerBuilder):
                 with lines.indent(f"for variant in {variants}:"):
                     with lines.indent("try:"):
                         lines.append(
-                            f"variants_map[variant.__dict__["
-                            f"'{discriminator.field}']] = variant"
+                            f"variants_map[{variant_tagger_expr}] = variant"
                         )
                     with lines.indent("except KeyError:"):
                         lines.append("continue")
diff --git a/mashumaro/types.py b/mashumaro/types.py
index 01f87c7..823a52e 100644
--- a/mashumaro/types.py
+++ b/mashumaro/types.py
@@ -1,6 +1,6 @@
 import decimal
 from dataclasses import dataclass
-from typing import Any, List, Optional, Type, Union
+from typing import Any, Callable, List, Optional, Type, Union
 
 from typing_extensions import Literal
 
@@ -93,6 +93,7 @@ class Discriminator:
     field: Optional[str] = None
     include_supertypes: bool = False
     include_subtypes: bool = False
+    variant_tagger_fn: Optional[Callable[[Any], Any]] = None
 
     def __post_init__(self) -> None:
         if not self.include_supertypes and not self.include_subtypes:


Test patch:
diff --git a/tests/test_discriminated_unions/test_parent_by_field.py b/tests/test_discriminated_unions/test_parent_by_field.py
index 1f0b7d0..d1a9b5b 100644
--- a/tests/test_discriminated_unions/test_parent_by_field.py
+++ b/tests/test_discriminated_unions/test_parent_by_field.py
@@ -145,6 +145,33 @@ class Bar(DataClassDictMixin):
     baz2: Annotated[Foo1, Discriminator(include_subtypes=True)]
 
 
+@dataclass
+class BaseVariantWitCustomTagger(DataClassDictMixin):
+    pass
+
+
+@dataclass
+class VariantWitCustomTaggerSub1(BaseVariantWitCustomTagger):
+    pass
+
+
+@dataclass
+class VariantWitCustomTaggerSub2(BaseVariantWitCustomTagger):
+    pass
+
+
+@dataclass
+class VariantWitCustomTaggerOwner(DataClassDictMixin):
+    x: Annotated[
+        BaseVariantWitCustomTagger,
+        Discriminator(
+            field="type",
+            include_subtypes=True,
+            variant_tagger_fn=lambda cls: cls.__name__.lower(),
+        ),
+    ]
+
+
 @pytest.mark.parametrize(
     ["variant_data", "variant"],
     [
@@ -257,3 +284,14 @@ def test_subclass_tree_with_class_without_field():
             "baz2": {"type": 4, "x1": 1, "x2": 2, "x": 42},
         }
     ) == Bar(baz1=Foo4(1, 2, 42), baz2=Foo2(1, 2))
+
+
+def test_by_field_with_custom_variant_tagger():
+    assert VariantWitCustomTaggerOwner.from_dict(
+        {"x": {"type": "variantwitcustomtaggersub1"}}
+    ) == VariantWitCustomTaggerOwner(VariantWitCustomTaggerSub1())
+    assert VariantWitCustomTaggerOwner.from_dict(
+        {"x": {"type": "variantwitcustomtaggersub2"}}
+    ) == VariantWitCustomTaggerOwner(VariantWitCustomTaggerSub2())
+    with pytest.raises(InvalidFieldValue):
+        VariantWitCustomTaggerOwner.from_dict({"x": {"type": "unknown"}})
diff --git a/tests/test_discriminated_unions/test_parent_via_config.py b/tests/test_discriminated_unions/test_parent_via_config.py
index d5b854b..e5eba15 100644
--- a/tests/test_discriminated_unions/test_parent_via_config.py
+++ b/tests/test_discriminated_unions/test_parent_via_config.py
@@ -203,6 +203,26 @@ class Bar4(Bar2):
     type = 4
 
 
+@dataclass
+class VariantWitCustomTagger(DataClassDictMixin):
+    class Config(BaseConfig):
+        discriminator = Discriminator(
+            field="type",
+            include_subtypes=True,
+            variant_tagger_fn=lambda cls: cls.__name__.lower(),
+        )
+
+
+@dataclass
+class VariantWitCustomTaggerSub1(VariantWitCustomTagger):
+    pass
+
+
+@dataclass
+class VariantWitCustomTaggerSub2(VariantWitCustomTagger):
+    pass
+
+
 def test_by_subtypes():
     assert VariantBySubtypes.from_dict(X_1) == VariantBySubtypesSub1(x=DT_STR)
 
@@ -385,3 +405,20 @@ def test_subclass_tree_with_class_without_field():
 
     assert Bar1.from_dict({"type": 3, "x1": 1, "x2": 2, "x": 42}) == Bar2(1, 2)
     assert Bar1.from_dict({"type": 4, "x1": 1, "x2": 2, "x": 42}) == Bar2(1, 2)
+
+
+def test_by_subtypes_with_custom_variant_tagger():
+    assert (
+        VariantWitCustomTagger.from_dict(
+            {"type": "variantwitcustomtaggersub1"}
+        )
+        == VariantWitCustomTaggerSub1()
+    )
+    assert (
+        VariantWitCustomTagger.from_dict(
+            {"type": "variantwitcustomtaggersub2"}
+        )
+        == VariantWitCustomTaggerSub2()
+    )
+    with pytest.raises(SuitableVariantNotFoundError):
+        VariantWitCustomTagger.from_dict({"type": "unknown"})
diff --git a/tests/test_discriminated_unions/test_union_by_field.py b/tests/test_discriminated_unions/test_union_by_field.py
index 2e1fc98..deea794 100644
--- a/tests/test_discriminated_unions/test_union_by_field.py
+++ b/tests/test_discriminated_unions/test_union_by_field.py
@@ -272,6 +272,28 @@ class ByFieldAndByFieldWithSubtypesInOneField(DataClassDictMixin):
     ]
 
 
+@dataclass
+class VariantWitCustomTagger1:
+    pass
+
+
+@dataclass
+class VariantWitCustomTagger2:
+    pass
+
+
+@dataclass
+class VariantWitCustomTaggerOwner(DataClassDictMixin):
+    x: Annotated[
+        Union[VariantWitCustomTagger1, VariantWitCustomTagger2],
+        Discriminator(
+            field="type",
+            include_supertypes=True,
+            variant_tagger_fn=lambda cls: cls.__name__.lower(),
+        ),
+    ]
+
+
 def test_by_field_with_supertypes():
     assert ByFieldWithSupertypes.from_dict(
         {
@@ -573,3 +595,14 @@ def test_tuple_with_discriminated_elements():
         ByFieldAndByFieldWithSubtypesInOneField.from_dict(
             {"x": [X_STR, X_STR]}
         )
+
+
+def test_by_field_with_subtypes_with_custom_variant_tagger():
+    assert VariantWitCustomTaggerOwner.from_dict(
+        {"x": {"type": "variantwitcustomtagger1"}}
+    ) == VariantWitCustomTaggerOwner(VariantWitCustomTagger1())
+    assert VariantWitCustomTaggerOwner.from_dict(
+        {"x": {"type": "variantwitcustomtagger2"}}
+    ) == VariantWitCustomTaggerOwner(VariantWitCustomTagger2())
+    with pytest.raises(InvalidFieldValue):
+        VariantWitCustomTaggerOwner.from_dict({"x": {"type": "unknown"}})