Skip to content

Commit fee8018

Browse files
committed
feat: Custom serrialization by field name
1 parent 7e34ff0 commit fee8018

3 files changed

Lines changed: 288 additions & 2 deletions

File tree

README.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,20 +106,42 @@ class SomeModel(db.Model, SerializerMixin):
106106
By default, `max_serialization_depth` is `math.inf` (unlimited), maintaining backward compatibility.
107107
See [Max recursion](#Max-recursion) for more details.
108108

109+
If you want to apply custom serialization logic to specific columns:
110+
```python
111+
# At call time
112+
result = item.to_dict(serialize_columns={
113+
'password': lambda v: '***' if v else None,
114+
'email': lambda v: v.lower() if v else None,
115+
'id': lambda v: str(v),
116+
})
117+
118+
# Set default for all instances of a model
119+
class SomeModel(db.Model, SerializerMixin):
120+
serialize_columns = {
121+
'password': lambda v: '***' if v else None,
122+
'email': lambda v: v.lower() if v else None,
123+
}
124+
...
125+
126+
result = item.to_dict()
127+
```
128+
Custom serializers in `serialize_columns` replace normal serialization for matching columns. The custom serializer function receives the field value and should return the serialized result.
129+
109130
If you want to define schema for all instances of particular SQLAlchemy model,
110131
add serialize properties to model definition:
111132
```python
112133
class SomeModel(db.Model, SerializerMixin):
113134
serialize_only = ('somefield.id',)
114135
serialize_rules = ()
115136
exclude_values = (None,) # Exclude None values for all instances
137+
serialize_columns = {'id': lambda v: str(v)} # Custom serializers per column
116138
...
117139
somefield = db.relationship('AnotherModel')
118140

119141
result = item.to_dict()
120142
```
121143
So the `result` in this case will be `{'somefield': [{'id': some_id}]}`
122-
***serialize_only***, ***serialize_rules***, and ***exclude_values*** work the same way as ***to_dict's*** arguments
144+
***serialize_only***, ***serialize_rules***, ***exclude_values***, and ***serialize_columns*** work the same way as ***to_dict's*** arguments
123145

124146

125147
# Advanced usage

sqlalchemy_serializer/serializer.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ class SerializerMixin:
6161
# Maximum depth for relationship recursion (default: unlimited)
6262
max_serialization_depth: float = math.inf
6363

64+
# Custom serializers per column name
65+
serialize_columns: dict = {}
66+
6467
def get_tzinfo(self):
6568
"""Callback to make serializer aware of user's timezone. Should be redefined if needed
6669
Example:
@@ -82,6 +85,7 @@ def to_dict(
8285
serialize_types=None,
8386
exclude_values=None,
8487
max_serialization_depth=None,
88+
serialize_columns=None,
8589
):
8690
"""Returns SQLAlchemy model's data in JSON compatible format
8791
@@ -100,6 +104,8 @@ def to_dict(
100104
:param exclude_values: iterable of hashable values to exclude from serialized output
101105
:param max_serialization_depth: maximum depth for relationship recursion
102106
(default: unlimited)
107+
:param serialize_columns: dict mapping column names to custom serializer functions.
108+
Custom serializers replace normal serialization for matching columns.
103109
:return: data: dict
104110
"""
105111
s = Serializer(
@@ -115,13 +121,14 @@ def to_dict(
115121
if max_serialization_depth is not None
116122
else self.max_serialization_depth
117123
),
124+
serialize_columns=serialize_columns or self.serialize_columns,
118125
)
119126
return s(self, only=only, extend=rules)
120127

121128

122129
Options = namedtuple(
123130
"Options",
124-
"date_format datetime_format time_format decimal_format tzinfo serialize_types exclude_values max_serialization_depth", # noqa: E501
131+
"date_format datetime_format time_format decimal_format tzinfo serialize_types exclude_values max_serialization_depth serialize_columns", # noqa: E501
125132
)
126133

127134

@@ -152,6 +159,7 @@ def __init__(self, **kwargs):
152159
"serialize_types": kwargs.get("serialize_types", ()),
153160
"exclude_values": exclude_values_set,
154161
"max_serialization_depth": kwargs.get("max_serialization_depth", math.inf),
162+
"serialize_columns": kwargs.get("serialize_columns", {}),
155163
}
156164
self.set_options(Options(**options_kwargs))
157165
self.init_callbacks()
@@ -282,6 +290,12 @@ def apply_callback(self, value):
282290

283291
def serialize_with_fork(self, value, key):
284292
"""Serialize value with a forked serializer"""
293+
# Check if there's a custom serializer for this column
294+
if self.opts.serialize_columns and key in self.opts.serialize_columns:
295+
custom_serializer = self.opts.serialize_columns[key]
296+
logger.debug("Apply custom serializer for key:%s", key)
297+
return custom_serializer(value)
298+
285299
serializer = self
286300
if self.is_forkable(value):
287301
# Check depth limit before forking

tests/test_serialize_columns.py

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
from .models import FlatModel, NestedModel
2+
3+
4+
def test_serialize_columns_class_level(get_instance):
5+
"""Test class-level serialize_columns attribute"""
6+
7+
class CustomFlatModel(FlatModel):
8+
serialize_columns = {
9+
"id": lambda v: str(v),
10+
"string": lambda v: v.upper() if v else None,
11+
}
12+
13+
i = get_instance(CustomFlatModel)
14+
data = i.to_dict()
15+
16+
# Custom serializer should be applied
17+
assert "id" in data
18+
assert isinstance(data["id"], str)
19+
assert data["id"] == str(i.id)
20+
21+
assert "string" in data
22+
assert data["string"] == i.string.upper()
23+
24+
# Other fields should use normal serialization
25+
assert "bool" in data
26+
assert data["bool"] == i.bool
27+
28+
29+
def test_serialize_columns_parameter_level(get_instance):
30+
"""Test parameter-level serialize_columns in to_dict()"""
31+
i = get_instance(FlatModel)
32+
data = i.to_dict(
33+
serialize_columns={
34+
"id": lambda v: f"ID_{v}",
35+
"string": lambda v: v.lower() if v else None,
36+
}
37+
)
38+
39+
# Custom serializer should be applied
40+
assert "id" in data
41+
assert data["id"] == f"ID_{i.id}"
42+
43+
assert "string" in data
44+
assert data["string"] == i.string.lower()
45+
46+
# Other fields should use normal serialization
47+
assert "bool" in data
48+
assert data["bool"] == i.bool
49+
50+
51+
def test_serialize_columns_replaces_normal_serialization(get_instance):
52+
"""Test that custom serializer replaces normal serialization"""
53+
i = get_instance(FlatModel)
54+
55+
# Custom serializer that returns a fixed value
56+
data = i.to_dict(
57+
serialize_columns={
58+
"id": lambda _: "CUSTOM_ID",
59+
"bool": lambda _: "CUSTOM_BOOL",
60+
}
61+
)
62+
63+
# Custom serializers should be used instead of normal serialization
64+
assert data["id"] == "CUSTOM_ID"
65+
assert data["bool"] == "CUSTOM_BOOL"
66+
67+
# Other fields should still use normal serialization
68+
assert data["string"] == i.string
69+
70+
71+
def test_serialize_columns_with_none_value(get_instance):
72+
"""Test custom serializer with None values"""
73+
i = get_instance(FlatModel)
74+
i.null = None
75+
76+
data = i.to_dict(
77+
serialize_columns={
78+
"null": lambda v: "NULL_VALUE" if v is None else v,
79+
}
80+
)
81+
82+
assert "null" in data
83+
assert data["null"] == "NULL_VALUE"
84+
85+
86+
def test_serialize_columns_with_callable_value(get_instance):
87+
"""Test custom serializer with callable values"""
88+
i = get_instance(FlatModel)
89+
90+
# The serializer should receive the result of the callable, not the callable itself
91+
data = i.to_dict(
92+
serialize_columns={
93+
"method": lambda v: f"METHOD_RESULT: {v}",
94+
},
95+
rules=("method",),
96+
)
97+
98+
assert "method" in data
99+
assert "METHOD_RESULT:" in data["method"]
100+
assert i.method() in data["method"]
101+
102+
103+
def test_serialize_columns_backward_compatibility(get_instance):
104+
"""Test that empty dict default doesn't break anything"""
105+
i = get_instance(FlatModel)
106+
107+
# Should work exactly as before when serialize_columns is not provided
108+
data = i.to_dict()
109+
110+
assert "id" in data
111+
assert data["id"] == i.id
112+
assert "string" in data
113+
assert data["string"] == i.string
114+
assert "bool" in data
115+
assert data["bool"] == i.bool
116+
117+
118+
def test_serialize_columns_parameter_overrides_class(get_instance):
119+
"""Test that parameter-level serialize_columns overrides class-level"""
120+
121+
class CustomFlatModel(FlatModel):
122+
serialize_columns = {
123+
"id": lambda v: f"CLASS_{v}",
124+
}
125+
126+
i = get_instance(CustomFlatModel)
127+
128+
# Parameter should override class-level
129+
data = i.to_dict(
130+
serialize_columns={
131+
"id": lambda v: f"PARAM_{v}",
132+
}
133+
)
134+
135+
assert "id" in data
136+
assert data["id"] == f"PARAM_{i.id}"
137+
assert "CLASS_" not in data["id"]
138+
139+
140+
def test_serialize_columns_with_nested_model(get_instance):
141+
"""Test custom serializer with nested models/relationships"""
142+
flat = get_instance(FlatModel)
143+
nested = get_instance(NestedModel, model_id=flat.id)
144+
nested.model = flat
145+
146+
data = nested.to_dict(
147+
rules=("model",),
148+
serialize_columns={
149+
"model": lambda v: {"custom": "serialized", "id": v.id} if v else None,
150+
},
151+
)
152+
153+
assert "model" in data
154+
assert data["model"]["custom"] == "serialized"
155+
assert data["model"]["id"] == flat.id
156+
# Normal serialization should be bypassed
157+
assert "string" not in data["model"]
158+
159+
160+
def test_serialize_columns_with_dict(get_instance):
161+
"""Test custom serializer with nested dictionaries"""
162+
i = get_instance(FlatModel)
163+
i.dict = {"key": 123, "key2": 456}
164+
165+
data = i.to_dict(
166+
rules=("dict",),
167+
serialize_columns={
168+
"dict": lambda v: {"custom": "dict", "original": v},
169+
},
170+
)
171+
172+
assert "dict" in data
173+
assert data["dict"]["custom"] == "dict"
174+
assert data["dict"]["original"] == {"key": 123, "key2": 456}
175+
176+
177+
def test_serialize_columns_passed_through_fork(get_instance):
178+
"""Test that custom serializer is passed through forks correctly"""
179+
flat = get_instance(FlatModel)
180+
nested = get_instance(NestedModel, model_id=flat.id)
181+
nested.model = flat
182+
183+
# Custom serializer for nested field
184+
data = nested.to_dict(
185+
rules=("model", "model.id"),
186+
serialize_columns={
187+
"id": lambda v: f"ID_{v}", # Should apply to nested model's id
188+
},
189+
)
190+
191+
assert "model" in data
192+
assert "id" in data["model"]
193+
# The custom serializer should be applied to the nested model's id
194+
assert data["model"]["id"] == f"ID_{flat.id}"
195+
196+
197+
def test_serialize_columns_only_applies_to_matching_keys(get_instance):
198+
"""Test that custom serializers only apply to matching column names"""
199+
i = get_instance(FlatModel)
200+
201+
data = i.to_dict(
202+
serialize_columns={
203+
"id": lambda _: "CUSTOM",
204+
"nonexistent": lambda _: "SHOULD_NOT_APPEAR",
205+
}
206+
)
207+
208+
# Only matching keys should use custom serializer
209+
assert data["id"] == "CUSTOM"
210+
assert "nonexistent" not in data
211+
212+
# Other fields should use normal serialization
213+
assert data["string"] == i.string
214+
assert data["bool"] == i.bool
215+
216+
217+
def test_serialize_columns_with_multiple_fields(get_instance):
218+
"""Test custom serializers with multiple fields"""
219+
i = get_instance(FlatModel)
220+
221+
data = i.to_dict(
222+
serialize_columns={
223+
"id": lambda v: str(v),
224+
"string": lambda v: v.upper() if v else None,
225+
"bool": lambda v: "YES" if v else "NO",
226+
}
227+
)
228+
229+
assert isinstance(data["id"], str)
230+
assert data["string"] == i.string.upper()
231+
assert data["bool"] == "YES" # i.bool is True by default
232+
233+
234+
def test_serialize_columns_class_level_with_nested(get_instance):
235+
"""Test class-level serialize_columns with nested structures"""
236+
237+
class CustomNestedModel(NestedModel):
238+
serialize_columns = {
239+
"id": lambda v: f"NESTED_ID_{v}",
240+
}
241+
242+
flat = get_instance(FlatModel)
243+
nested = get_instance(CustomNestedModel, model_id=flat.id)
244+
nested.model = flat
245+
246+
data = nested.to_dict(rules=("id", "model", "model.id"))
247+
248+
# Class-level custom serializer should apply
249+
assert data["id"] == f"NESTED_ID_{nested.id}"
250+
assert data["model"]["id"] == f"NESTED_ID_{flat.id}"

0 commit comments

Comments
 (0)