Repository: wyfo/apischema

Issue:
Setting to control serialisation of None fields
Let's say I want to avoid serialising dataclass fields that have `None` values. To make round-tripping work we can assume `None` is the default value for the field as well.

From reading the documentation there seem to be two options:
1. Decorate the class with `@with_fields_set`, and do not set the value at construction time
2. Annotate the field `: Union[T, UndefinedType] = Undefined` instead of `: Optional[T] = None`

Unfortunately both of these approaches require changes to the dataclass. 2 is also particularly awkward for code that uses the class and the behaviour of 1 is rather subtle. Is there a way to configure the serialiser to obtain this behaviour without touching the class? And in such a way that generating a serialisation JSON schema would understand that the field cannot be `null`?

Discussion:

Patch:
diff --git a/apischema/graphql/resolvers.py b/apischema/graphql/resolvers.py
index 6f14fb6..94957f3 100644
--- a/apischema/graphql/resolvers.py
+++ b/apischema/graphql/resolvers.py
@@ -83,13 +83,15 @@ def partial_serialization_method_factory(
     @lru_cache()
     def factory(tp: AnyType) -> SerializationMethod:
         return PartialSerializationMethodVisitor(
-            False,
-            aliaser,
-            False,
-            default_conversion,
-            False,
-            False,
-            PassThroughOptions(),
+            additional_properties=False,
+            aliaser=aliaser,
+            check_type=False,
+            default_conversion=default_conversion,
+            exclude_defaults=False,
+            exclude_none=False,
+            exclude_unset=False,
+            fall_back_on_any=False,
+            pass_through_options=PassThroughOptions(),
         ).visit_with_conv(tp, conversion)
 
     return factory
diff --git a/apischema/json_schema/schema.py b/apischema/json_schema/schema.py
index 6a5ef57..997cf62 100644
--- a/apischema/json_schema/schema.py
+++ b/apischema/json_schema/schema.py
@@ -351,7 +351,12 @@ class SerializationSchemaBuilder(
 
     @staticmethod
     def _field_required(field: ObjectField):
-        return field.skip_if is None
+        from apischema import settings
+
+        skip_if = field.skip_if(
+            settings.serialization.exclude_defaults, settings.serialization.exclude_none
+        )
+        return skip_if is None
 
     def object(self, tp: AnyType, fields: Sequence[ObjectField]) -> JsonSchema:
         result = super().object(tp, fields)
diff --git a/apischema/objects/fields.py b/apischema/objects/fields.py
index 80a0703..e2a53a5 100644
--- a/apischema/objects/fields.py
+++ b/apischema/objects/fields.py
@@ -181,15 +181,18 @@ class ObjectField:
     def skip(self) -> SkipMetadata:
         return self.metadata.get(SKIP_METADATA, SkipMetadata())
 
-    @property
-    def skip_if(self) -> Optional[Callable[[Any], Any]]:
+    def skip_if(
+        self, default: bool = False, none: bool = False
+    ) -> Optional[Callable[[Any], Any]]:
         skip_if = self.skip.serialization_if
-        if self.default_factory is not None and self.skip.serialization_default:
+        if self.default_factory is not None and (
+            self.skip.serialization_default or default
+        ):
             default = self.default_factory()  # type: ignore
             skip_if = merge_skip_if(skip_if, lambda obj: obj == default)
         if is_union_of(self.type, UndefinedType):
             skip_if = merge_skip_if(skip_if, lambda obj: obj is Undefined)
-        if self.none_as_undefined:
+        if self.none_as_undefined or (none and is_union_of(self.type, NoneType)):
             skip_if = merge_skip_if(skip_if, lambda obj: obj is None)
         return skip_if
 
diff --git a/apischema/objects/getters.py b/apischema/objects/getters.py
index 1fab127..b222015 100644
--- a/apischema/objects/getters.py
+++ b/apischema/objects/getters.py
@@ -3,6 +3,7 @@ from typing import (
     Any,
     Callable,
     Mapping,
+    Optional,
     Sequence,
     Type,
     TypeVar,
@@ -23,7 +24,12 @@ from apischema.visitor import Unsupported
 
 @cache
 def object_fields(
-    tp: AnyType, deserialization: bool = False, serialization: bool = False
+    tp: AnyType,
+    deserialization: bool = False,
+    serialization: bool = False,
+    default: Optional[
+        Callable[[type], Optional[Sequence[ObjectField]]]
+    ] = ObjectVisitor._default_fields,
 ) -> Mapping[str, ObjectField]:
     class GetFields(ObjectVisitor[Sequence[ObjectField]]):
         def _skip_field(self, field: ObjectField) -> bool:
@@ -31,6 +37,10 @@ def object_fields(
                 field.skip.serialization and deserialization
             )
 
+        @staticmethod
+        def _default_fields(cls: type) -> Optional[Sequence[ObjectField]]:
+            return None if default is None else default(cls)
+
         def object(
             self, cls: Type, fields: Sequence[ObjectField]
         ) -> Sequence[ObjectField]:
diff --git a/apischema/objects/visitor.py b/apischema/objects/visitor.py
index d7704e7..9573564 100644
--- a/apischema/objects/visitor.py
+++ b/apischema/objects/visitor.py
@@ -53,6 +53,29 @@ class ObjectVisitor(Visitor[Result]):
     def _skip_field(self, field: ObjectField) -> bool:
         raise NotImplementedError
 
+    @staticmethod
+    def _default_fields(cls: type) -> Optional[Sequence[ObjectField]]:
+        from apischema import settings
+
+        return settings.default_object_fields(cls)
+
+    def _override_fields(
+        self, tp: AnyType, fields: Sequence[ObjectField]
+    ) -> Sequence[ObjectField]:
+
+        origin = get_origin_or_type(tp)
+        if isinstance(origin, type):
+            default_fields = self._default_fields(origin)
+            if default_fields is not None:
+                if get_args(tp):
+                    sub = dict(zip(get_parameters(origin), get_args(tp)))
+                    default_fields = [
+                        replace(f, type=substitute_type_vars(f.type, sub))
+                        for f in default_fields
+                    ]
+                return default_fields
+        return fields
+
     def _object(self, tp: AnyType, fields: Sequence[ObjectField]) -> Result:
         fields = [f for f in fields if not self._skip_field(f)]
         aliaser = get_class_aliaser(get_origin_or_type(tp))
@@ -77,7 +100,7 @@ class ObjectVisitor(Visitor[Result]):
             for name in types
             if name in by_name and by_name[name].kind != self._field_kind_filtered
         ]
-        return self._object(tp, object_fields)
+        return self._object(tp, self._override_fields(tp, object_fields))
 
     def object(self, tp: AnyType, fields: Sequence[ObjectField]) -> Result:
         raise NotImplementedError
@@ -89,7 +112,7 @@ class ObjectVisitor(Visitor[Result]):
             ObjectField(name, type_, name not in defaults, default=defaults.get(name))
             for name, type_ in types.items()
         ]
-        return self._object(tp, fields)
+        return self._object(tp, self._override_fields(tp, fields))
 
     def typed_dict(
         self, tp: AnyType, types: Mapping[str, AnyType], required_keys: Collection[str]
@@ -98,23 +121,12 @@ class ObjectVisitor(Visitor[Result]):
             ObjectField(name, type_, name in required_keys, default=Undefined)
             for name, type_ in types.items()
         ]
-        return self._object(tp, fields)
+        return self._object(tp, self._override_fields(tp, fields))
 
     def unsupported(self, tp: AnyType) -> Result:
-        from apischema import settings
-
-        origin = get_origin_or_type(tp)
-        if isinstance(origin, type):
-            fields = settings.default_object_fields(origin)
-            if fields is not None:
-                if get_args(tp):
-                    sub = dict(zip(get_parameters(origin), get_args(tp)))
-                    fields = [
-                        replace(f, type=substitute_type_vars(f.type, sub))
-                        for f in fields
-                    ]
-                return self._object(origin, fields)
-        return super().unsupported(tp)
+        dummy: list = []
+        fields = self._override_fields(tp, dummy)
+        return super().unsupported(tp) if fields is dummy else self._object(tp, fields)
 
 
 class DeserializationObjectVisitor(ObjectVisitor[Result]):
diff --git a/apischema/serialization/__init__.py b/apischema/serialization/__init__.py
index 457b062..1fa4d0a 100644
--- a/apischema/serialization/__init__.py
+++ b/apischema/serialization/__init__.py
@@ -72,6 +72,8 @@ class SerializationMethodVisitor(
         aliaser: Aliaser,
         check_type: bool,
         default_conversion: DefaultConversion,
+        exclude_defaults: bool,
+        exclude_none: bool,
         exclude_unset: bool,
         fall_back_on_any: bool,
         pass_through_options: PassThroughOptions,
@@ -80,6 +82,8 @@ class SerializationMethodVisitor(
         self.additional_properties = additional_properties
         self.aliaser = aliaser
         self.check_type = check_type
+        self.exclude_defaults = exclude_defaults
+        self.exclude_none = exclude_none
         self.exclude_unset = exclude_unset
         self.fall_back_on_any = fall_back_on_any
         self.pass_through_options = pass_through_options
@@ -103,6 +107,8 @@ class SerializationMethodVisitor(
                 aliaser=self.aliaser,
                 conversions=self._conversions,
                 default_conversion=self.default_conversion,
+                exclude_defaults=self.exclude_defaults,
+                exclude_none=self.exclude_none,
                 exclude_unset=self.exclude_unset,
                 options=self.pass_through_options,
             )
@@ -117,6 +123,8 @@ class SerializationMethodVisitor(
             self.check_type,
             self._conversions,
             self.default_conversion,
+            self.exclude_defaults,
+            self.exclude_none,
             self.exclude_unset,
             self.fall_back_on_any,
             self.pass_through_options,
@@ -208,12 +216,11 @@ class SerializationMethodVisitor(
         for field in fields:
             serialize_field = self.visit_with_conv(field.type, field.serialization)
             alias = self.aliaser(field.alias)
+            skip_if = field.skip_if(self.exclude_defaults, self.exclude_none)
             if field.is_aggregate:
-                aggregate_fields.append((field.name, serialize_field, field.skip_if))
-            elif field.skip_if is not None:
-                skipped_if_fields.append(
-                    (field.name, alias, serialize_field, field.skip_if)
-                )
+                aggregate_fields.append((field.name, serialize_field, skip_if))
+            elif skip_if is not None:
+                skipped_if_fields.append((field.name, alias, serialize_field, skip_if))
             elif serialize_field is identity:
                 identity_fields.append((field.name, alias))
             else:
@@ -448,6 +455,8 @@ def serialization_method_factory(
     check_type: Optional[bool],
     conversion: Optional[AnyConversion],
     default_conversion: Optional[DefaultConversion],
+    exclude_defaults: Optional[bool],
+    exclude_none: Optional[bool],
     exclude_unset: Optional[bool],
     fall_back_on_any: Optional[bool],
     pass_through: Optional[PassThroughOptions],
@@ -461,6 +470,8 @@ def serialization_method_factory(
             opt_or(aliaser, settings.aliaser),
             opt_or(check_type, settings.serialization.check_type),
             opt_or(default_conversion, settings.serialization.default_conversion),
+            opt_or(exclude_defaults, settings.serialization.exclude_defaults),
+            opt_or(exclude_none, settings.serialization.exclude_none),
             opt_or(exclude_unset, settings.serialization.exclude_unset),
             opt_or(fall_back_on_any, settings.serialization.fall_back_on_any),
             opt_or(pass_through, settings.serialization.pass_through),
@@ -477,6 +488,8 @@ def serialization_method(
     check_type: bool = None,
     conversion: AnyConversion = None,
     default_conversion: DefaultConversion = None,
+    exclude_defaults: bool = None,
+    exclude_none: bool = None,
     exclude_unset: bool = None,
     fall_back_on_any: bool = None,
     pass_through: PassThroughOptions = None,
@@ -487,6 +500,8 @@ def serialization_method(
         check_type,
         conversion,
         default_conversion,
+        exclude_defaults,
+        exclude_none,
         exclude_unset,
         fall_back_on_any,
         pass_through,
@@ -506,6 +521,8 @@ def serialize(
     check_type: bool = None,
     conversion: AnyConversion = None,
     default_conversion: DefaultConversion = None,
+    exclude_defaults: bool = None,
+    exclude_none: bool = None,
     exclude_unset: bool = None,
     fall_back_on_any: bool = None,
     pass_through: PassThroughOptions = None,
@@ -522,6 +539,8 @@ def serialize(
     check_type: bool = None,
     conversion: AnyConversion = None,
     default_conversion: DefaultConversion = None,
+    exclude_defaults: bool = None,
+    exclude_none: bool = None,
     exclude_unset: bool = None,
     fall_back_on_any: bool = True,
     pass_through: PassThroughOptions = None,
@@ -539,6 +558,8 @@ def serialize(
     check_type: bool = None,
     conversion: AnyConversion = None,
     default_conversion: DefaultConversion = None,
+    exclude_defaults: bool = None,
+    exclude_none: bool = None,
     exclude_unset: bool = None,
     fall_back_on_any: bool = None,
     pass_through: PassThroughOptions = None,
@@ -554,6 +575,8 @@ def serialize(
         check_type,
         conversion,
         default_conversion,
+        exclude_defaults,
+        exclude_none,
         exclude_unset,
         fall_back_on_any,
         pass_through,
@@ -565,6 +588,8 @@ def serialization_default(
     additional_properties: bool = None,
     aliaser: Aliaser = None,
     default_conversion: DefaultConversion = None,
+    exclude_defaults: bool = None,
+    exclude_none: bool = None,
     exclude_unset: bool = None,
 ) -> SerializationMethod:
     factory = serialization_method_factory(
@@ -573,6 +598,8 @@ def serialization_default(
         False,
         None,
         default_conversion,
+        exclude_defaults,
+        exclude_none,
         exclude_unset,
         False,
         # Annotations are lost in default fallback, so there will be `Any` everywhere;
diff --git a/apischema/serialization/pass_through.py b/apischema/serialization/pass_through.py
index cbb38e5..a36ae25 100644
--- a/apischema/serialization/pass_through.py
+++ b/apischema/serialization/pass_through.py
@@ -63,12 +63,16 @@ class PassThroughVisitor(
         additional_properties: bool,
         aliaser: Aliaser,
         default_conversion: DefaultConversion,
+        exclude_defaults: bool,
+        exclude_none: bool,
         exclude_unset: bool,
         options: PassThroughOptions,
     ):
         super().__init__(default_conversion)
         self.additional_properties = additional_properties
         self.aliaser = aliaser
+        self.exclude_defaults = exclude_defaults
+        self.exclude_none = exclude_none
         self.exclude_unset = exclude_unset
         self.options = options
 
@@ -139,7 +143,10 @@ class PassThroughVisitor(
                 dataclass_options.properties_fields
                 or not (field.pattern_properties or field.additional_properties)
             )
-            and (dataclass_options.skipped_if_fields or field.skip_if is None)
+            and (
+                dataclass_options.skipped_if_fields
+                or field.skip_if(self.exclude_defaults, self.exclude_none) is None
+            )
             and self.visit_with_conv(field.type, field.serialization)
             for field in fields
         )
@@ -186,6 +193,8 @@ def pass_through(
     aliaser: Aliaser = None,
     conversions: AnyConversion = None,
     default_conversion: DefaultConversion = None,
+    exclude_defaults: bool = None,
+    exclude_none: bool = None,
     exclude_unset: bool = None,
     options: PassThroughOptions = None,
 ) -> bool:
@@ -195,6 +204,8 @@ def pass_through(
         opt_or(additional_properties, settings.additional_properties),
         opt_or(aliaser, settings.aliaser),
         opt_or(default_conversion, settings.serialization.default_conversion),
+        opt_or(exclude_defaults, settings.serialization.exclude_defaults),
+        opt_or(exclude_none, settings.serialization.exclude_none),
         opt_or(exclude_unset, settings.serialization.exclude_unset),
         opt_or(options, settings.serialization.pass_through),
     ).visit_with_conv(tp, conversions)
diff --git a/apischema/settings.py b/apischema/settings.py
index 663140b..47ac238 100644
--- a/apischema/settings.py
+++ b/apischema/settings.py
@@ -54,5 +54,7 @@ class settings(metaclass=MetaSettings):
         check_type: bool = False
         fall_back_on_any: bool = False
         default_conversion: DefaultConversion = default_serialization
+        exclude_defaults: bool = False
+        exclude_none: bool = False
         exclude_unset: bool = True
         pass_through: PassThroughOptions = PassThroughOptions()
diff --git a/docs/data_model.md b/docs/data_model.md
index cc8ec0b..4a14607 100644
--- a/docs/data_model.md
+++ b/docs/data_model.md
@@ -219,6 +219,9 @@ Thus, support of dataclass-like types (*attrs*, *SQLAlchemy* traditional mappers
 
 Another way to set object fields is to directly modify *apischema* default behavior, using `apischema.settings.default_object_fields`.
 
+!!! note
+    `set_object_fields`/`settings.default_object_fields` can be used to override existing fields. Current fields can be retrieved using `apischema.objects.object_fields`.
+
 ```python
 from collections.abc import Sequence
 from typing import Optional
diff --git a/docs/de_serialization.md b/docs/de_serialization.md
index d0d5979..c3f37f9 100644
--- a/docs/de_serialization.md
+++ b/docs/de_serialization.md
@@ -216,6 +216,21 @@ Sometimes, some fields must be serialized, even with their default value; this b
 !!! note
     This metadata has effect only in combination with `with_fields_set` decorator.
 
+### Exclude fields with default value or `None`
+
+Fields metadata [`apischema.skip`](data_model.md#skip-field-serialization-depending-on-condition) already allows skipping fields serialization depending on a condition, for example if the field is `None` or equal to its default value. However, it must be added on each concerned fields, and that can be tedious when you want to set that behavior globally.
+
+That's why *apischema* provides the two following settings:
+
+- `settings.serialization.exclude_defaults`: whether fields which are equal to their default values should be excluded from serialization; default `False`
+- `settings.serialization.exclude_none`: whether fields which are equal to `None` should be excluded from serialization; default `False`
+
+These settings can also be set directly using `serialize` parameters, like in the following example:
+
+```python
+{!exclude_defaults_none.py!}
+```
+
 ### TypedDict additional properties
 
 `TypedDict` can contain additional keys, which are not serialized by default. Setting `additional_properties` parameter to `True` (or `apischema.settings.additional_properties`) will toggle on their serialization (without aliasing).
diff --git a/examples/exclude_defaults_none.py b/examples/exclude_defaults_none.py
new file mode 100644
index 0000000..bdb6002
--- /dev/null
+++ b/examples/exclude_defaults_none.py
@@ -0,0 +1,14 @@
+from dataclasses import dataclass
+from typing import Optional
+
+from apischema import serialize
+
+
+@dataclass
+class Foo:
+    bar: int = 0
+    baz: Optional[str] = None
+
+
+assert serialize(Foo, Foo(), exclude_defaults=True) == {}
+assert serialize(Foo, Foo(), exclude_none=True) == {"bar": 0}


Test patch:
diff --git a/tests/integration/test_object_fields_overriding.py b/tests/integration/test_object_fields_overriding.py
new file mode 100644
index 0000000..010e8b9
--- /dev/null
+++ b/tests/integration/test_object_fields_overriding.py
@@ -0,0 +1,30 @@
+import sys
+from dataclasses import dataclass, replace
+from typing import Optional
+
+from pytest import mark, raises
+
+from apischema import ValidationError, deserialize, serialize
+from apischema.metadata import none_as_undefined
+from apischema.objects import object_fields, set_object_fields
+
+
+@dataclass
+class Foo:
+    bar: Optional[str] = None
+
+
+@mark.skipif(sys.version_info < (3, 8), reason="dataclasses.replace bug with InitVar")
+def test_object_fields_overriding():
+    set_object_fields(Foo, [])
+    assert serialize(Foo, Foo()) == {}
+    set_object_fields(
+        Foo,
+        [
+            replace(f, metadata=none_as_undefined | f.metadata)
+            for f in object_fields(Foo, default=None).values()
+        ],
+    )
+    assert serialize(Foo, Foo()) == {}
+    with raises(ValidationError):
+        deserialize(Foo, {"bar": None})