Repository: Fatal1ty/mashumaro (language: python)

Issue:
Add an option to omit values equal to defaults on serialization
**Is your feature request related to a problem? Please describe.**
There are cases when it's preferable to omit default values on serialization. It can potentially reduce the storage space used or the amount of data transferred.

**Describe the solution you'd like**
Add new `omit_default` config and dialect option.

**Describe alternatives you've considered**
We can remove values equal to defaults using post-serialize hook but it's not as efficient.


Discussion / hints:

Fix patch:
diff --git a/README.md b/README.md
index 9694aa60..dc24b42f 100644
--- a/README.md
+++ b/README.md
@@ -59,6 +59,7 @@ Table of contents
         * [`aliases` config option](#aliases-config-option)
         * [`serialize_by_alias` config option](#serialize_by_alias-config-option)
         * [`omit_none` config option](#omit_none-config-option)
+        * [`omit_default` config option](#omit_default-config-option)
         * [`namedtuple_as_dict` config option](#namedtuple_as_dict-config-option)
         * [`allow_postponed_evaluation` config option](#allow_postponed_evaluation-config-option)
         * [`dialect` config option](#dialect-config-option)
@@ -70,6 +71,7 @@ Table of contents
     * [Dialects](#dialects)
       * [`serialization_strategy` dialect option](#serialization_strategy-dialect-option)
       * [`omit_none` dialect option](#omit_none-dialect-option)
+      * [`omit_default` dialect option](#omit_default-dialect-option)
       * [Changing the default dialect](#changing-the-default-dialect)
     * [Discriminator](#discriminator)
       * [Subclasses distinguishable by a field](#subclasses-distinguishable-by-a-field)
@@ -1159,19 +1161,47 @@ default when this option is enabled. You can mix this config option with
 [`omit_none`](#add-omit_none-keyword-argument) keyword argument.
 
 ```python
-from dataclasses import dataclass, field
+from dataclasses import dataclass
 from typing import Optional
-from mashumaro import DataClassDictMixin, field_options
+from mashumaro import DataClassDictMixin
 from mashumaro.config import BaseConfig
 
 @dataclass
 class DataClass(DataClassDictMixin):
-    x: Optional[int] = None
+    x: Optional[int] = 42
 
     class Config(BaseConfig):
         omit_none = True
 
-DataClass().to_dict()  # {}
+DataClass(x=None).to_dict()  # {}
+```
+
+#### `omit_default` config option
+
+When this option enabled, all the fields that have values equal to the defaults
+or the default_factory results will be excluded during serialization.
+
+```python
+from dataclasses import dataclass, field
+from typing import List, Optional, Tuple
+from mashumaro import DataClassDictMixin, field_options
+from mashumaro.config import BaseConfig
+
+@dataclass
+class Foo:
+    foo: str
+
+@dataclass
+class DataClass(DataClassDictMixin):
+    a: int = 42
+    b: Tuple[int, ...] = field(default=(1, 2, 3))
+    c: List[Foo] = field(default_factory=lambda: [Foo("foo")])
+    d: Optional[str] = None
+
+    class Config(BaseConfig):
+        omit_default = True
+
+DataClass(a=42, b=(1, 2, 3), c=[Foo("foo")]).to_dict()  # {}
 ```
 
 #### `namedtuple_as_dict` config option
@@ -1532,6 +1562,11 @@ but for the dialect scope. You can register custom [`SerializationStrategy`](#se
 This dialect option has the same meaning as the
 [similar config option](#omit_none-config-option) but for the dialect scope.
 
+#### `omit_default` dialect option
+
+This dialect option has the same meaning as the
+[similar config option](#omitdefault-config-option) but for the dialect scope.
+
 #### Changing the default dialect
 
 You can change the default serialization and deserialization methods for
diff --git a/mashumaro/config.py b/mashumaro/config.py
index 90d658d3..e0bcfd38 100644
--- a/mashumaro/config.py
+++ b/mashumaro/config.py
@@ -43,6 +43,7 @@ class BaseConfig:
     allow_postponed_evaluation: bool = True
     dialect: Optional[Type[Dialect]] = None
     omit_none: Union[bool, Literal[Sentinel.MISSING]] = Sentinel.MISSING
+    omit_default: Union[bool, Literal[Sentinel.MISSING]] = Sentinel.MISSING
     orjson_options: Optional[int] = 0
     json_schema: Dict[str, Any] = {}
     discriminator: Optional[Discriminator] = None
diff --git a/mashumaro/core/meta/code/builder.py b/mashumaro/core/meta/code/builder.py
index 8bd8dbd3..98bcd241 100644
--- a/mashumaro/core/meta/code/builder.py
+++ b/mashumaro/core/meta/code/builder.py
@@ -3,6 +3,7 @@
 import sys
 import types
 import typing
+import uuid
 from contextlib import contextmanager
 
 # noinspection PyProtectedMember
@@ -43,13 +44,14 @@
     is_hashable,
     is_init_var,
     is_literal,
+    is_named_tuple,
     is_optional,
     is_type_var_any,
     resolve_type_params,
     substitute_type_params,
     type_name,
 )
-from mashumaro.core.meta.types.common import FieldContext, ValueSpec
+from mashumaro.core.meta.types.common import FieldContext, NoneType, ValueSpec
 from mashumaro.core.meta.types.pack import PackerRegistry
 from mashumaro.core.meta.types.unpack import (
     SubtypeUnpackerBuilder,
@@ -215,14 +217,19 @@ def metadatas(self) -> typing.Dict[str, typing.Mapping[str, typing.Any]]:
             # https://github.com/python/mypy/issues/1362
         }
 
-    def get_field_default(self, name: str) -> typing.Any:
+    def get_field_default(
+        self, name: str, call_factory: bool = False
+    ) -> typing.Any:
         field = self.dataclass_fields.get(name)  # type: ignore
         # https://github.com/python/mypy/issues/1362
         if field:
             if field.default is not MISSING:
                 return field.default
             else:
-                return field.default_factory
+                if call_factory and field.default_factory is not MISSING:
+                    return field.default_factory()
+                else:
+                    return field.default_factory
         else:
             return self.namespace.get(name, MISSING)
 
@@ -691,9 +698,7 @@ def get_pack_method_default_flag_values(
             TO_DICT_ADD_OMIT_NONE_FLAG, cls
         )
         if omit_none_feature:
-            omit_none = self._get_dialect_or_config_option(
-                "omit_none", False, None
-            )
+            omit_none = self._get_dialect_or_config_option("omit_none", False)
             kw_param_names.append("omit_none")
             kw_param_values.append("True" if omit_none else "False")
 
@@ -856,6 +861,10 @@ def _add_pack_method_lines(self, method_name: str) -> None:
             )
             serialize_by_alias = self.get_config().serialize_by_alias
             omit_none = self._get_dialect_or_config_option("omit_none", False)
+            omit_default = self._get_dialect_or_config_option(
+                "omit_default", False
+            )
+            force_value = omit_default
             packers = {}
             aliases = {}
             nullable_fields = set()
@@ -864,7 +873,7 @@ def _add_pack_method_lines(self, method_name: str) -> None:
                 if self.metadatas.get(fname, {}).get("serialize") == "omit":
                     continue
                 packer, alias, could_be_none = self._get_field_packer(
-                    fname, ftype, config
+                    fname, ftype, config, force_value
                 )
                 packers[fname] = packer
                 if alias:
@@ -879,41 +888,73 @@ def _add_pack_method_lines(self, method_name: str) -> None:
                 and (omit_none or omit_none_feature)
                 or by_alias_feature
                 and aliases
+                or omit_default
             ):
                 kwargs = "kwargs"
                 self.add_line("kwargs = {}")
                 for fname, packer in packers.items():
+                    if force_value:
+                        self.add_line(f"value = self.{fname}")
                     alias = aliases.get(fname)
+                    default = self.get_field_default(fname, call_factory=True)
                     if fname in nullable_fields:
                         if (
                             packer == "value"
                             and not omit_none
                             and not omit_none_feature
+                            and not (omit_default and default is None)
                         ):
                             self._pack_method_set_value(
-                                fname, alias, by_alias_feature, f"self.{fname}"
+                                fname=fname,
+                                alias=alias,
+                                by_alias_feature=by_alias_feature,
+                                packed_value=(
+                                    "value" if force_value else f"self.{fname}"
+                                ),
+                                omit_default=omit_default,
                             )
                             continue
-                        self.add_line(f"value = self.{fname}")
+                        if not force_value:  # to add it only once
+                            self.add_line(f"value = self.{fname}")
                         with self.indent("if value is not None:"):
                             self._pack_method_set_value(
-                                fname, alias, by_alias_feature, packer
+                                fname=fname,
+                                alias=alias,
+                                by_alias_feature=by_alias_feature,
+                                packed_value=packer,
+                                omit_default=(
+                                    omit_default and default is not None
+                                ),
                             )
                         if omit_none and not omit_none_feature:
                             continue
+                        elif omit_default and default is None:
+                            continue
                         with self.indent("else:"):
                             if omit_none_feature:
                                 with self.indent("if not omit_none:"):
                                     self._pack_method_set_value(
-                                        fname, alias, by_alias_feature, "None"
+                                        fname=fname,
+                                        alias=alias,
+                                        by_alias_feature=by_alias_feature,
+                                        packed_value="None",
+                                        omit_default=False,
                                     )
                             else:
                                 self._pack_method_set_value(
-                                    fname, alias, by_alias_feature, "None"
+                                    fname=fname,
+                                    alias=alias,
+                                    by_alias_feature=by_alias_feature,
+                                    packed_value="None",
+                                    omit_default=False,
                                 )
                     else:
                         self._pack_method_set_value(
-                            fname, alias, by_alias_feature, packer
+                            fname=fname,
+                            alias=alias,
+                            by_alias_feature=by_alias_feature,
+                            packed_value=packer,
+                            omit_default=omit_default,
                         )
             else:
                 kwargs_parts = []
@@ -962,6 +1003,29 @@ def _pack_method_set_value(
         alias: typing.Optional[str],
         by_alias_feature: bool,
         packed_value: str,
+        omit_default: bool,
+    ) -> None:
+        if omit_default:
+            default = self.get_field_default(fname, call_factory=True)
+            if default is not MISSING:
+                default_literal = self._get_field_default_literal(
+                    self.get_field_default(fname, call_factory=True)
+                )
+                comp_op = "is not" if default_literal == "None" else "!="
+                with self.indent(f"if value {comp_op} {default_literal}:"):
+                    return self.__pack_method_set_value(
+                        fname, alias, by_alias_feature, packed_value
+                    )
+        return self.__pack_method_set_value(
+            fname, alias, by_alias_feature, packed_value
+        )
+
+    def __pack_method_set_value(
+        self,
+        fname: str,
+        alias: typing.Optional[str],
+        by_alias_feature: bool,
+        packed_value: str,
     ) -> None:
         if by_alias_feature and alias is not None:
             with self.indent("if by_alias:"):
@@ -1067,6 +1131,7 @@ def _get_field_packer(
         fname: str,
         ftype: typing.Type,
         config: typing.Type[BaseConfig],
+        force_value: bool = False,
     ) -> typing.Tuple[str, typing.Optional[str], bool]:
         metadata = self.metadatas.get(fname, {})
         alias = metadata.get("alias")
@@ -1078,7 +1143,7 @@ def _get_field_packer(
             or is_optional(ftype, self.get_field_resolved_type_params(fname))
             or self.get_field_default(fname) is None
         )
-        value = "value" if could_be_none else f"self.{fname}"
+        value = "value" if could_be_none or force_value else f"self.{fname}"
         packer = PackerRegistry.get(
             ValueSpec(
                 type=ftype,
@@ -1135,3 +1200,15 @@ def _get_dialect_or_config_option(
             if value is not Sentinel.MISSING:
                 return value
         return default
+
+    def _get_field_default_literal(self, value: typing.Any) -> str:
+        if isinstance(
+            value, (str, int, float, bool, NoneType)  # type: ignore
+        ):
+            return repr(value)
+        elif isinstance(value, tuple) and not is_named_tuple(type(value)):
+            return repr(value)
+        else:
+            name = f"v_{uuid.uuid4().hex}"
+            self.ensure_object_imported(value, name)
+            return name
diff --git a/mashumaro/dialect.py b/mashumaro/dialect.py
index b3ec3f04..c91b49e0 100644
--- a/mashumaro/dialect.py
+++ b/mashumaro/dialect.py
@@ -16,3 +16,4 @@
 class Dialect:
     serialization_strategy: Dict[Any, SerializationStrategyValueType] = {}
     omit_none: Union[bool, Literal[Sentinel.MISSING]] = Sentinel.MISSING
+    omit_default: Union[bool, Literal[Sentinel.MISSING]] = Sentinel.MISSING


Test patch:
diff --git a/tests/test_dialect.py b/tests/test_dialect.py
index 0c9efb27..a1f7457c 100644
--- a/tests/test_dialect.py
+++ b/tests/test_dialect.py
@@ -1,6 +1,16 @@
-from dataclasses import dataclass
+from dataclasses import dataclass, field
 from datetime import date, datetime
-from typing import Generic, List, NamedTuple, Optional, TypeVar, Union
+from typing import (
+    FrozenSet,
+    Generic,
+    List,
+    NamedTuple,
+    Optional,
+    Set,
+    Tuple,
+    TypeVar,
+    Union,
+)
 
 import pytest
 from typing_extensions import TypedDict
@@ -68,6 +78,14 @@ class NotOmitNoneDialect(Dialect):
     omit_none = False
 
 
+class OmitDefaultDialect(Dialect):
+    omit_default = True
+
+
+class NotOmitDefaultDialect(Dialect):
+    omit_default = False
+
+
 @dataclass
 class DataClassWithoutDialects(DataClassDictMixin):
     dt: date
@@ -259,6 +277,118 @@ class Config(BaseConfig):
         code_generation_options = [ADD_DIALECT_SUPPORT]
 
 
+@dataclass
+class DataClassWithDefaultAndOmitDefaultDialect(DataClassDictMixin):
+    a: int = 42
+    b: float = 3.14
+    c: str = "foo"
+    d: bool = False
+    e: None = None
+    f: type(None) = None
+    g: Tuple[int] = (1, 2, 3)
+    h: MyNamedTuple = field(default_factory=lambda: MyNamedTuple())
+    i: List[int] = field(default_factory=list)
+    j: List[int] = field(default_factory=lambda: [1, 2, 3])
+    k: Set[int] = field(default_factory=set)
+    l: Set[int] = field(default_factory=lambda: {1, 2, 3})
+    m: FrozenSet[int] = field(default_factory=frozenset)
+    n: FrozenSet[int] = field(default_factory=lambda: frozenset({1, 2, 3}))
+
+    class Config(BaseConfig):
+        dialect = OmitDefaultDialect
+
+
+@dataclass
+class DataClassWithDefaultAndOmitDefaultDialectAndOmitDefaultFalse(
+    DataClassDictMixin
+):
+    a: int = 42
+    b: float = 3.14
+    c: str = "foo"
+    d: bool = False
+    e: None = None
+    f: type(None) = None
+    g: Tuple[int] = (1, 2, 3)
+    h: MyNamedTuple = field(default_factory=lambda: MyNamedTuple())
+    i: List[int] = field(default_factory=list)
+    j: List[int] = field(default_factory=lambda: [1, 2, 3])
+    k: Set[int] = field(default_factory=set)
+    l: Set[int] = field(default_factory=lambda: {1, 2, 3})
+    m: FrozenSet[int] = field(default_factory=frozenset)
+    n: FrozenSet[int] = field(default_factory=lambda: frozenset({1, 2, 3}))
+
+    class Config(BaseConfig):
+        dialect = OmitDefaultDialect
+        omit_default = False
+
+
+@dataclass
+class DataClassWithDefaultAndNotOmitDefaultDialectAndOmitDefaultTrue(
+    DataClassDictMixin
+):
+    a: int = 42
+    b: float = 3.14
+    c: str = "foo"
+    d: bool = False
+    e: None = None
+    f: type(None) = None
+    g: Tuple[int] = (1, 2, 3)
+    h: MyNamedTuple = field(default_factory=lambda: MyNamedTuple())
+    i: List[int] = field(default_factory=list)
+    j: List[int] = field(default_factory=lambda: [1, 2, 3])
+    k: Set[int] = field(default_factory=set)
+    l: Set[int] = field(default_factory=lambda: {1, 2, 3})
+    m: FrozenSet[int] = field(default_factory=frozenset)
+    n: FrozenSet[int] = field(default_factory=lambda: frozenset({1, 2, 3}))
+
+    class Config(BaseConfig):
+        dialect = NotOmitDefaultDialect
+        omit_default = True
+
+
+@dataclass
+class DataClassWithDefaultAndEmptyDialect(DataClassDictMixin):
+    a: int = 42
+    b: float = 3.14
+    c: str = "foo"
+    d: bool = False
+    e: None = None
+    f: type(None) = None
+    g: Tuple[int] = (1, 2, 3)
+    h: MyNamedTuple = field(default_factory=lambda: MyNamedTuple())
+    i: List[int] = field(default_factory=list)
+    j: List[int] = field(default_factory=lambda: [1, 2, 3])
+    k: Set[int] = field(default_factory=set)
+    l: Set[int] = field(default_factory=lambda: {1, 2, 3})
+    m: FrozenSet[int] = field(default_factory=frozenset)
+    n: FrozenSet[int] = field(default_factory=lambda: frozenset({1, 2, 3}))
+
+    class Config(BaseConfig):
+        dialect = EmptyDialect
+        omit_default = True
+
+
+@dataclass
+class DataClassWithDefaultAndDialectSupport(DataClassDictMixin):
+    a: int = 42
+    b: float = 3.14
+    c: str = "foo"
+    d: bool = False
+    e: None = None
+    f: type(None) = None
+    g: Tuple[int] = (1, 2, 3)
+    h: MyNamedTuple = field(default_factory=lambda: MyNamedTuple())
+    i: List[int] = field(default_factory=list)
+    j: List[int] = field(default_factory=lambda: [1, 2, 3])
+    k: Set[int] = field(default_factory=set)
+    l: Set[int] = field(default_factory=lambda: {1, 2, 3})
+    m: FrozenSet[int] = field(default_factory=frozenset)
+    n: FrozenSet[int] = field(default_factory=lambda: frozenset({1, 2, 3}))
+
+    class Config(BaseConfig):
+        code_generation_options = [ADD_DIALECT_SUPPORT]
+
+
 def test_default_dialect():
     dt = date.today()
     ordinal = dt.toordinal()
@@ -985,3 +1115,49 @@ def test_dataclass_omit_none_dialects():
     assert DataClassWithOptionalAndDialectSupport().to_dict(
         dialect=EmptyDialect
     ) == {"x": None}
+
+
+def test_dataclass_omit_default_dialects():
+    complete_dict = {
+        "a": 42,
+        "b": 3.14,
+        "c": "foo",
+        "d": False,
+        "e": None,
+        "f": None,
+        "g": [1],
+        "h": [{"dt": "2022-01-01", "i": 999}, {"dt": "2022-01-01", "i": 999}],
+        "i": [],
+        "j": [1, 2, 3],
+        "k": [],
+        "l": [1, 2, 3],
+        "m": [],
+        "n": [1, 2, 3],
+    }
+    assert DataClassWithDefaultAndOmitDefaultDialect().to_dict() == {}
+    assert (
+        DataClassWithDefaultAndOmitDefaultDialectAndOmitDefaultFalse().to_dict()
+        == {}
+    )
+    assert (
+        DataClassWithDefaultAndNotOmitDefaultDialectAndOmitDefaultTrue().to_dict()
+        == complete_dict
+    )
+    assert DataClassWithDefaultAndEmptyDialect().to_dict() == {}
+    assert DataClassWithDefaultAndDialectSupport().to_dict() == complete_dict
+    assert (
+        DataClassWithDefaultAndDialectSupport().to_dict(
+            dialect=OmitDefaultDialect
+        )
+        == {}
+    )
+    assert (
+        DataClassWithDefaultAndDialectSupport().to_dict(
+            dialect=NotOmitDefaultDialect
+        )
+        == complete_dict
+    )
+    assert (
+        DataClassWithDefaultAndDialectSupport().to_dict(dialect=EmptyDialect)
+        == complete_dict
+    )