Skip to content

Commit 58fb6e8

Browse files
authored
Merge pull request #68 from n0nSmoker/feat/exclude-values
Exclude values
2 parents 9640909 + 774f66a commit 58fb6e8

6 files changed

Lines changed: 220 additions & 25 deletions

File tree

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ test:
55
TEST_FILE=$(FILE) docker-compose up --build --abort-on-container-exit
66

77
test-pypi:
8-
uv run pytest -xvrs --color=yes -m pypi tests/test_pypi_readiness.py::test_pypi_version_can_be_installed_and_used
8+
uv run pytest -xvrs --color=yes -m pypi tests/test_pypi_readiness.py
99

1010
format:
1111
uv run ruff format .

README.md

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,19 +80,30 @@ You can use negative rules in `only` param too.
8080
So `item.to_dict(only=('somefield', -'somefield.id'))`
8181
will return `somefiled` without `id`. See [Negative rules in ONLY section](#Negative-rules-in-ONLY-section)
8282

83+
If you want to exclude specific values from the serialized output (e.g., `None` values):
84+
```python
85+
result = item.to_dict(exclude_values=(None,))
86+
```
87+
This will exclude all fields that have `None` as their value. You can exclude multiple values:
88+
```python
89+
result = item.to_dict(exclude_values=(None, True, ''))
90+
```
91+
**Note** that `exclude_values` works with hashable values only. It filters values after serialization, so it works with nested dictionaries and models as well.
92+
8393
If you want to define schema for all instances of particular SQLAlchemy model,
8494
add serialize properties to model definition:
8595
```python
8696
class SomeModel(db.Model, SerializerMixin):
8797
serialize_only = ('somefield.id',)
8898
serialize_rules = ()
99+
exclude_values = (None,) # Exclude None values for all instances
89100
...
90101
somefield = db.relationship('AnotherModel')
91102

92103
result = item.to_dict()
93104
```
94105
So the `result` in this case will be `{'somefield': [{'id': some_id}]}`
95-
***serialize_only*** and ***serialize_rules*** work the same way as ***to_dict's*** arguments
106+
***serialize_only***, ***serialize_rules***, and ***exclude_values*** work the same way as ***to_dict's*** arguments
96107

97108

98109
# Advanced usage
@@ -176,6 +187,38 @@ dict(
176187
id=1
177188
)
178189
)
190+
191+
192+
# Exclude specific values
193+
item.to_dict(exclude_values=(None,))
194+
195+
dict(
196+
id=1,
197+
string='Some string!',
198+
boolean=True,
199+
flat_id=1,
200+
rel=[dict(
201+
id=1,
202+
string='Some string!',
203+
boolean=True,
204+
non_sqlalchemy_dict=dict(qwerty=123)
205+
)]
206+
)
207+
# Note: 'null' field is excluded because its value is None
208+
209+
210+
# Exclude values with nested dictionaries
211+
item.dict = {"key": 123, "null_key": None, "key2": 456}
212+
item.to_dict(rules=("dict",), exclude_values=(None,))
213+
214+
dict(
215+
...
216+
dict=dict(
217+
key=123,
218+
key2=456
219+
)
220+
# Note: 'null_key' is excluded from nested dict
221+
)
179222
```
180223
# Recursive models and trees
181224
If your models have references to each other or you work with large trees

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "sqlalchemy-serializer"
3-
version = "1.5.1"
3+
version = "1.5.2"
44
description = "Mixin for SQLAlchemy models serialization without pain"
55
authors = [
66
{name = "yuri.boiko", email = "yuri.boiko.dev@gmail.com"}

sqlalchemy_serializer/serializer.py

Lines changed: 49 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
SERIALIZER_DEFAULT_TIME_FORMAT = "%H:%M"
2323
SERIALIZER_DEFAULT_DECIMAL_FORMAT = "{}"
2424

25+
# sentinel value for unspecified key since None and Ellipsis are valid keys
26+
_UNSPECIFIED = object()
27+
2528

2629
class SerializerMixin:
2730
"""Mixin for retrieving public fields of sqlAlchemy-model in json-compatible format
@@ -37,12 +40,15 @@ class SerializerMixin:
3740
# Additions to default schema. Can include negative rules
3841
serialize_rules: tuple = ()
3942

40-
# Extra serialising functions
43+
# Extra serializing functions
4144
serialize_types: tuple = ()
4245

4346
# Custom list of fields to serialize in this model
4447
serializable_keys: tuple = ()
4548

49+
# Iterable of hashable values to exclude from serialized output
50+
exclude_values: Iterable = ()
51+
4652
date_format = SERIALIZER_DEFAULT_DATE_FORMAT
4753
datetime_format = SERIALIZER_DEFAULT_DATETIME_FORMAT
4854
time_format = SERIALIZER_DEFAULT_TIME_FORMAT
@@ -70,6 +76,7 @@ def to_dict(
7076
tzinfo=None,
7177
decimal_format=None,
7278
serialize_types=None,
79+
exclude_values=None,
7380
):
7481
"""Returns SQLAlchemy model's data in JSON compatible format
7582
@@ -85,6 +92,7 @@ def to_dict(
8592
:param decimal_format: str
8693
:param serialize_types:
8794
:param tzinfo: datetime.tzinfo converts datetimes to local user timezone
95+
:param exclude_values: iterable of hashable values to exclude from serialized output
8896
:return: data: dict
8997
"""
9098
s = Serializer(
@@ -94,13 +102,14 @@ def to_dict(
94102
decimal_format=decimal_format or self.decimal_format,
95103
tzinfo=tzinfo or self.get_tzinfo(),
96104
serialize_types=serialize_types or self.serialize_types,
105+
exclude_values=exclude_values or self.exclude_values,
97106
)
98107
return s(self, only=only, extend=rules)
99108

100109

101110
Options = namedtuple(
102111
"Options",
103-
"date_format datetime_format time_format decimal_format tzinfo serialize_types",
112+
"date_format datetime_format time_format decimal_format tzinfo serialize_types exclude_values", # noqa: E501
104113
)
105114

106115

@@ -117,6 +126,9 @@ class Serializer:
117126
def __init__(self, **kwargs):
118127
self.set_serialization_depth(0)
119128
# Provide defaults for Options if not specified
129+
exclude_values = kwargs.get("exclude_values")
130+
exclude_values_set = set(exclude_values) if exclude_values else None
131+
120132
options_kwargs = {
121133
"date_format": kwargs.get("date_format", SERIALIZER_DEFAULT_DATE_FORMAT),
122134
"datetime_format": kwargs.get(
@@ -126,6 +138,7 @@ def __init__(self, **kwargs):
126138
"decimal_format": kwargs.get("decimal_format", SERIALIZER_DEFAULT_DECIMAL_FORMAT),
127139
"tzinfo": kwargs.get("tzinfo"),
128140
"serialize_types": kwargs.get("serialize_types", ()),
141+
"exclude_values": exclude_values_set,
129142
}
130143
self.set_options(Options(**options_kwargs))
131144
self.init_callbacks()
@@ -150,6 +163,18 @@ def set_serialization_depth(self, value: int):
150163
def set_options(self, opts: Options):
151164
self.opts = opts
152165

166+
def should_exclude(self, value) -> bool:
167+
"""Check if value should be excluded based on exclude_values"""
168+
if self.opts.exclude_values is None:
169+
return False
170+
try:
171+
# if value is not hashable, we cannot compare it with exclude_values
172+
hash(value)
173+
except TypeError:
174+
return False
175+
176+
return value in self.opts.exclude_values
177+
153178
def init_callbacks(self):
154179
"""Initialize callbacks"""
155180
self.serialize_types = (
@@ -206,29 +231,31 @@ def fork(self, key: str) -> "Serializer":
206231
logger.debug("Fork serializer for key:%s", key)
207232
return serializer
208233

209-
def serialize(self, value, **kwargs):
234+
def serialize(self, value, key=_UNSPECIFIED):
210235
"""Orchestrates the serialization process.
211236
212237
Args:
213238
value: The value to be serialized.
214-
**kwargs: Only to ensure that no key is passed
215-
since None and Ellipsis are valid keys.
239+
key: The key to be serialized.
216240
217241
Returns:
218242
The serialized value.
219-
220243
"""
221244
if self.is_valid_callable(value):
222245
value = value()
223246
logger.debug("Process callable resulting type:%s", get_type(value))
224247

225-
if kwargs:
226-
if "key" in kwargs:
227-
# since None and ... are valid keys
228-
return self.serialize_with_fork(value=value, key=kwargs["key"])
229-
raise ValueError("Malformed structure of kwargs. Only `key` accepted")
248+
if not self.should_exclude(value):
249+
try:
250+
if key is not _UNSPECIFIED: # since None and Ellipsis are valid keys
251+
return self.serialize_with_fork(value=value, key=key)
252+
return self.apply_callback(value=value)
253+
254+
except IsNotSerializable:
255+
logger.warning("Can not serialize type:%s", get_type(value))
230256

231-
return self.apply_callback(value=value)
257+
logger.debug("Skip value:%s", value)
258+
return _UNSPECIFIED
232259

233260
def apply_callback(self, value):
234261
"""Apply a proper callback to serialize the value
@@ -241,6 +268,7 @@ def apply_callback(self, value):
241268
raise IsNotSerializable(f"Unserializable type:{get_type(value)} value:{value}")
242269

243270
def serialize_with_fork(self, value, key):
271+
"""Serialize value with a forked serializer"""
244272
serializer = self
245273
if self.is_forkable(value):
246274
serializer = self.fork(key=key)
@@ -250,13 +278,9 @@ def serialize_with_fork(self, value, key):
250278
def serialize_iter(self, value: Iterable) -> list:
251279
res = []
252280
for v in value:
253-
try:
254-
r = self.serialize(v)
255-
except IsNotSerializable: # FIXME: Why we swallow exception only in iterable?
256-
logger.warning("Can not serialize type:%s", get_type(v))
257-
continue
258-
259-
res.append(r)
281+
result = self.serialize(v)
282+
if result is not _UNSPECIFIED:
283+
res.append(result)
260284
return res
261285

262286
def serialize_dict(self, value: dict) -> dict:
@@ -265,7 +289,9 @@ def serialize_dict(self, value: dict) -> dict:
265289
if self.schema.is_included(k): # TODO: Skip check if is NOT greedy
266290
logger.debug("Serialize key:%s type:%s of dict", k, get_type(v))
267291

268-
res[k] = self.serialize(value=v, key=k)
292+
result = self.serialize(value=v, key=k)
293+
if result is not _UNSPECIFIED:
294+
res[k] = result
269295
else:
270296
logger.debug("Skip key:%s of dict", k)
271297
return res
@@ -287,7 +313,9 @@ def serialize_model(self, value) -> dict:
287313
get_type(v),
288314
get_type(value),
289315
)
290-
res[k] = self.serialize(value=v, key=k)
316+
result = self.serialize(value=v, key=k)
317+
if result is not _UNSPECIFIED:
318+
res[k] = result
291319

292320
else:
293321
logger.debug("Skip key:%s of model:%s", k, get_type(value))

tests/test_exclude_values.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
from .models import FlatModel, NestedModel
2+
3+
4+
def test_exclude_values_none(get_instance):
5+
"""Test excluding None values from serialized output"""
6+
i = get_instance(FlatModel)
7+
data = i.to_dict(exclude_values=(None,))
8+
9+
# None value should be excluded
10+
assert "null" not in data
11+
# Other fields should still be present
12+
assert "id" in data
13+
assert "string" in data
14+
assert "bool" in data
15+
16+
17+
def test_exclude_values_multiple(get_instance):
18+
"""Test excluding multiple values from serialized output"""
19+
i = get_instance(FlatModel)
20+
data = i.to_dict(exclude_values=(None, True, i.string))
21+
22+
assert "null" not in data
23+
assert "bool" not in data
24+
assert "string" not in data
25+
26+
# Other fields should still be present
27+
assert "id" in data
28+
assert "date" in data
29+
assert "datetime" in data
30+
assert "time" in data
31+
assert "uuid" in data
32+
33+
34+
def test_exclude_values_with_rules(get_instance):
35+
"""Test that exclude_values works with rules parameter"""
36+
i = get_instance(FlatModel)
37+
data = i.to_dict(rules=("null", "prop"), exclude_values=(None,))
38+
39+
# null field should be included in rules but excluded by exclude_values
40+
assert "null" not in data
41+
# prop should be present
42+
assert "prop" in data
43+
44+
45+
def test_exclude_values_with_only(get_instance):
46+
"""Test that exclude_values works with only parameter"""
47+
i = get_instance(FlatModel)
48+
data = i.to_dict(only=("id", "null", "string"), exclude_values=(None,))
49+
50+
# null field should be included in only but excluded by exclude_values
51+
assert "null" not in data
52+
# Other fields should be present
53+
assert "id" in data
54+
assert "string" in data
55+
56+
57+
def test_exclude_values_empty_collection(get_instance):
58+
"""Test that empty exclude_values doesn't filter anything"""
59+
i = get_instance(FlatModel)
60+
data = i.to_dict(exclude_values=())
61+
62+
# All fields should be present including null
63+
assert "null" in data
64+
assert data["null"] is None
65+
assert "id" in data
66+
assert "string" in data
67+
68+
69+
def test_exclude_values_nested_dict(get_instance):
70+
"""Test that exclude_values works with nested dictionaries"""
71+
i = get_instance(FlatModel)
72+
# Create a dict with None value
73+
i.dict = {"key": 123, "null_key": None, "key2": 456}
74+
data = i.to_dict(rules=("dict",), exclude_values=(None,))
75+
76+
assert "dict" in data
77+
assert "null_key" not in data["dict"]
78+
assert "key" in data["dict"]
79+
assert "key2" in data["dict"]
80+
81+
82+
def test_exclude_values_nested_model(get_instance):
83+
"""Test that exclude_values works with nested models"""
84+
flat = get_instance(FlatModel)
85+
nested = get_instance(NestedModel, model_id=flat.id)
86+
nested.model = flat
87+
88+
data = nested.to_dict(rules=("model",), exclude_values=(None,))
89+
90+
assert "model" in data
91+
assert "null" not in data
92+
assert "null" not in data["model"]
93+
94+
# Other fields should be present
95+
assert "id" in data["model"]
96+
assert "string" in data["model"]
97+
98+
def test_exclude_values_class_level(get_instance):
99+
"""Test class-level exclude_values works"""
100+
101+
class ExcludeNullFlatModel(FlatModel):
102+
exclude_values = (None,)
103+
104+
i = get_instance(ExcludeNullFlatModel)
105+
i.null = None
106+
i.string = "foobar"
107+
data = i.to_dict(only=("id", "null", "string"))
108+
assert "null" not in data
109+
assert "id" in data
110+
assert "string" in data and data["string"] == "foobar"
111+
112+
# Nested model class level exclude_values
113+
class ExcludeNullNestedModel(NestedModel):
114+
exclude_values = (None,)
115+
116+
nested = get_instance(ExcludeNullNestedModel)
117+
flat = get_instance(FlatModel)
118+
flat.null = None
119+
nested.model = flat
120+
121+
data = nested.to_dict(rules=("model",))
122+
assert "model" in data
123+
assert "null" in data
124+
assert "null" not in data["model"]

0 commit comments

Comments
 (0)