Repository: Fatal1ty/mashumaro (language: python)

Issue:
Add an option to avoid collection data types copying
**Is your feature request related to a problem? Please describe.**
Currently, all collection data types are serialized as copies to prevent mutation, but for mixins like `DataClassORJSONMixin` we can avoid that and improve serialization performance. The list of mixins that use their own dialects:
* `DataClassORJSONMixin`
* `DataClassMessagePackMixin`
* `DataClassTOMLMixin`

**Describe the solution you'd like**
Introduce `no_copy` dialect option.


Discussion / hints:

Fix patch:
diff --git a/README.md b/README.md
index e748d83c..5ab18b41 100644
--- a/README.md
+++ b/README.md
@@ -72,6 +72,7 @@ Table of contents
       * [`serialization_strategy` dialect option](#serialization_strategy-dialect-option)
       * [`omit_none` dialect option](#omit_none-dialect-option)
       * [`omit_default` dialect option](#omit_default-dialect-option)
+      * [`no_copy` dialect option](#no_copy-dialect-option)
       * [Changing the default dialect](#changing-the-default-dialect)
     * [Discriminator](#discriminator)
       * [Subclasses distinguishable by a field](#subclasses-distinguishable-by-a-field)
@@ -1567,6 +1568,29 @@ This dialect option has the same meaning as the
 This dialect option has the same meaning as the
 [similar config option](#omitdefault-config-option) but for the dialect scope.
 
+#### `no_copy` dialect option
+
+By default, all collection data types are serialized as a copy to prevent
+mutation of the original collection. As an example, if a dataclass contains
+a field of type `list[str]`, then it will be serialized as follows:
+```python
+[value for value in value]
+```
+This expression will copy the original list, so you can safely mutate it after.
+The downside is that copying is always slower that using a reference to the
+original collection.
+
+In some cases we're sure that mutation doesn't take place, and we can
+benefit from avoiding unnecessary copies. To prevent copying you can set
+`no_copy` to `True`.
+
+> [!NOTE]\
+> This option is enabled in the dialects that are used within the
+> following mixins:
+> * [`DataClassORJSONMixin`](#dataclassorjsonmixin)
+> * [`DataClassMessagePackMixin`](#dataclassmessagepackmixin)
+> * [`DataClassTOMLMixin`](#dataclasstomlmixin)
+
 #### Changing the default dialect
 
 You can change the default serialization and deserialization methods for
diff --git a/mashumaro/core/meta/code/builder.py b/mashumaro/core/meta/code/builder.py
index b68d112c..f5c36de6 100644
--- a/mashumaro/core/meta/code/builder.py
+++ b/mashumaro/core/meta/code/builder.py
@@ -1161,6 +1161,7 @@ def _get_field_packer(
                     metadata=metadata,
                 ),
                 could_be_none=False,
+                no_copy=self._get_dialect_or_config_option("no_copy", False),
             )
         )
         return packer, alias, could_be_none
diff --git a/mashumaro/core/meta/types/common.py b/mashumaro/core/meta/types/common.py
index 3e33e38a..0c0e9498 100644
--- a/mashumaro/core/meta/types/common.py
+++ b/mashumaro/core/meta/types/common.py
@@ -78,6 +78,7 @@ class ValueSpec:
     could_be_none: bool = True
     annotated_type: Optional[Type] = None
     owner: Optional[Type] = None
+    no_copy: bool = False
 
     def __setattr__(self, key: str, value: Any) -> None:
         if key == "type":
diff --git a/mashumaro/core/meta/types/pack.py b/mashumaro/core/meta/types/pack.py
index 475b1aac..47eee503 100644
--- a/mashumaro/core/meta/types/pack.py
+++ b/mashumaro/core/meta/types/pack.py
@@ -673,6 +673,18 @@ def inner_expr(
                 )
             )
 
+    def _check_sequence_pass_through(ie: Expression) -> bool:
+        return spec.no_copy and ie == "value"
+
+    def _make_sequence_expression(ie: Expression) -> Expression:
+        return f"[{ie} for value in {spec.expression}]"
+
+    def _check_mapping_pass_through(ke: Expression, ve: Expression) -> bool:
+        return spec.no_copy and ke == "key" and ve == "value"
+
+    def _make_mapping_expression(ke: Expression, ve: Expression) -> Expression:
+        return f"{{{ke}: {ve} for key, value in {spec.expression}.items()}}"
+
     if issubclass(spec.origin_type, typing.ByteString):  # type: ignore
         spec.builder.ensure_object_imported(encodebytes)
         return f"encodebytes({spec.expression}).decode()"
@@ -686,32 +698,44 @@ def inner_expr(
     elif ensure_generic_collection_subclass(
         spec, typing.List, typing.Deque, typing.AbstractSet
     ):
-        return f"[{inner_expr()} for value in {spec.expression}]"
+        ie = inner_expr()
+        if _check_sequence_pass_through(ie):
+            return spec.expression
+        return _make_sequence_expression(ie)
     elif ensure_generic_mapping(spec, args, typing.ChainMap):
+        ke = inner_expr(0, "key")
+        ve = inner_expr(1)
+        if _check_mapping_pass_through(ke, ve):
+            return spec.expression
         return (
-            f'[{{{inner_expr(0, "key")}: {inner_expr(1)} '
-            f"for key, value in m.items()}} "
+            f"[{{{ke}: {ve} for key, value in m.items()}} "
             f"for m in {spec.expression}.maps]"
         )
     elif ensure_generic_mapping(spec, args, typing.OrderedDict):
-        return (
-            f'{{{inner_expr(0, "key")}: {inner_expr(1)} '
-            f"for key, value in {spec.expression}.items()}}"
-        )
+        ke = inner_expr(0, "key")
+        ve = inner_expr(1)
+        if _check_mapping_pass_through(ke, ve):
+            return spec.expression
+        return _make_mapping_expression(ke, ve)
     elif ensure_generic_mapping(spec, args, typing.Counter):
-        return (
-            f'{{{inner_expr(0, "key")}: {inner_expr(1, v_type=int)} '
-            f"for key, value in {spec.expression}.items()}}"
-        )
+        ke = inner_expr(0, "key")
+        ve = inner_expr(1, v_type=int)
+        if _check_mapping_pass_through(ke, ve):
+            return spec.expression
+        return _make_mapping_expression(ke, ve)
     elif is_typed_dict(spec.origin_type):
         return pack_typed_dict(spec)
     elif ensure_generic_mapping(spec, args, typing.Mapping):
-        return (
-            f'{{{inner_expr(0, "key")}: {inner_expr(1)} '
-            f"for key, value in {spec.expression}.items()}}"
-        )
+        ke = inner_expr(0, "key")
+        ve = inner_expr(1)
+        if _check_mapping_pass_through(ke, ve):
+            return spec.expression
+        return _make_mapping_expression(ke, ve)
     elif ensure_generic_collection_subclass(spec, typing.Sequence):
-        return f"[{inner_expr()} for value in {spec.expression}]"
+        ie = inner_expr()
+        if _check_sequence_pass_through(ie):
+            return spec.expression
+        return _make_sequence_expression(ie)
 
 
 @register
diff --git a/mashumaro/dialect.py b/mashumaro/dialect.py
index c91b49e0..e036083e 100644
--- a/mashumaro/dialect.py
+++ b/mashumaro/dialect.py
@@ -17,3 +17,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
+    no_copy: bool = False
diff --git a/mashumaro/mixins/msgpack.py b/mashumaro/mixins/msgpack.py
index 0b4cdeb9..b762a764 100644
--- a/mashumaro/mixins/msgpack.py
+++ b/mashumaro/mixins/msgpack.py
@@ -15,6 +15,7 @@
 
 
 class MessagePackDialect(Dialect):
+    no_copy = True
     serialization_strategy = {
         bytes: pass_through,  # type: ignore
         bytearray: {
diff --git a/mashumaro/mixins/orjson.py b/mashumaro/mixins/orjson.py
index c414400e..600349fe 100644
--- a/mashumaro/mixins/orjson.py
+++ b/mashumaro/mixins/orjson.py
@@ -18,6 +18,7 @@
 
 
 class OrjsonDialect(Dialect):
+    no_copy = True
     serialization_strategy = {
         datetime: {"serialize": pass_through},
         date: {"serialize": pass_through},
diff --git a/mashumaro/mixins/toml.py b/mashumaro/mixins/toml.py
index 7791ffbf..54302c8c 100644
--- a/mashumaro/mixins/toml.py
+++ b/mashumaro/mixins/toml.py
@@ -21,6 +21,7 @@
 
 
 class TOMLDialect(Dialect):
+    no_copy = True
     omit_none = True
     serialization_strategy = {
         datetime: pass_through,


Test patch:
diff --git a/tests/test_dialect.py b/tests/test_dialect.py
index a1f7457c..89bae8df 100644
--- a/tests/test_dialect.py
+++ b/tests/test_dialect.py
@@ -1,3 +1,5 @@
+import collections
+import typing
 from dataclasses import dataclass, field
 from datetime import date, datetime
 from typing import (
@@ -15,7 +17,7 @@
 import pytest
 from typing_extensions import TypedDict
 
-from mashumaro import DataClassDictMixin
+from mashumaro import DataClassDictMixin, pass_through
 from mashumaro.config import ADD_DIALECT_SUPPORT, BaseConfig
 from mashumaro.dialect import Dialect
 from mashumaro.exceptions import BadDialect
@@ -1161,3 +1163,40 @@ def test_dataclass_omit_default_dialects():
         DataClassWithDefaultAndDialectSupport().to_dict(dialect=EmptyDialect)
         == complete_dict
     )
+
+
+def test_dialect_no_copy():
+    class NoCopyDialect(Dialect):
+        no_copy = True
+        serialization_strategy = {int: {"serialize": pass_through}}
+
+    @dataclass
+    class DataClass(DataClassDictMixin):
+        a: List[str]
+        b: Set[str]
+        c: typing.ChainMap[str, str]
+        d: typing.OrderedDict[str, str]
+        e: typing.Counter[str]
+        f: typing.Dict[str, str]
+        g: typing.Sequence[str]
+
+        class Config(BaseConfig):
+            dialect = NoCopyDialect
+
+    obj = DataClass(
+        a=["foo"],
+        b={"foo"},
+        c=collections.ChainMap({"foo": "bar"}),
+        d=collections.OrderedDict({"foo": "bar"}),
+        e=collections.Counter({"foo": 1}),
+        f={"foo": "bar"},
+        g=["foo"],
+    )
+    data = obj.to_dict()
+    assert data["a"] is obj.a
+    assert data["b"] is obj.b
+    assert data["c"] is obj.c
+    assert data["d"] is obj.d
+    assert data["e"] is obj.e
+    assert data["f"] is obj.f
+    assert data["g"] is obj.g