Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support dot notation in model field names #604

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Current
-------

- Ensure `basePath` is always a path
- Add `dot_escape` kwarg to `fields.Raw` to prevent nested property access

0.12.1 (2018-09-28)
-------------------
Expand Down
52 changes: 52 additions & 0 deletions doc/marshalling.rst
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,58 @@ you can specify a default value to return instead of :obj:`None`.
}


Nested Field Names
------------------

By default, '.' is used as a separator to indicate nested properties when values
are fetched from objects:

.. code-block:: python

data = {
'address': {
'country': 'UK',
'postcode': 'CO1'
}
}

model = {
'address.country': fields.String,
'address.postcode': fields.String,
}

marshal(data, model)
{'address.country': 'UK', 'address.postcode': 'CO1'}

If the object to be marshalled has '.' characters within a single field name,
nested property access can be prevented by passing `dot_escape=True` and escaping
the '.' with a backslash:

.. code-block:: python

data = {
'address.country': 'UK',
'address.postcode': 'CO1',
'user.name': {
'first': 'John',
'last': 'Smith',
}
}

model = {
'address\.country': fields.String(dot_escape=True),
'address\.postcode': fields.String(dot_escape=True),
'user\.name.first': fields.String(dot_escape=True),
'user\.name.last': fields.String(dot_escape=True),
}

marshal(data, model)
{'address.country': 'UK',
'address.postcode': 'CO1',
'user.name.first': 'John',
'user.name.last': 'Smith'}


Custom Fields & Multiple Values
-------------------------------

Expand Down
43 changes: 38 additions & 5 deletions flask_restplus/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,39 @@ def is_indexable_but_not_string(obj):
return not hasattr(obj, "strip") and hasattr(obj, "__iter__")


def get_value(key, obj, default=None):
'''Helper for pulling a keyed value off various types of objects'''
def get_value(key, obj, default=None, dot_escape=False):
'''Helper for pulling a keyed value off various types of objects

:param bool dot_escape: Allow escaping of '.' character in field names to
indicate non-nested property access

>>> data = {'a': 'foo', b: {'c': 'bar', 'd.e': 'baz'}}}
>>> get_value('a', data)
'foo'

>>> get_value('b.c', data)
'bar'

>>> get_value('x', data, default='foobar')
'foobar'

>>> get_value('b.d\.e', data, dot_escape=True)
'baz'
'''
if isinstance(key, int):
return _get_value_for_key(key, obj, default)
elif callable(key):
return key(obj)
else:
return _get_value_for_keys(key.split('.'), obj, default)
keys = (
[
k.replace("\.", ".")
for k in re.split(r"(?<!\\)\.", key)
]
if dot_escape
else key.split(".")
)
return _get_value_for_keys(keys, obj, default)


def _get_value_for_keys(keys, obj, default):
Expand Down Expand Up @@ -104,6 +129,8 @@ class Raw(object):
:param bool readonly: Is the field read only ? (for documentation purpose)
:param example: An optional data example (for documentation purpose)
:param callable mask: An optional mask function to be applied to output
:param bool dot_escape: Allow escaping of '.' character in field names to
indicate non-nested property access
'''
#: The JSON/Swagger schema type
__schema_type__ = 'object'
Expand All @@ -113,7 +140,8 @@ class Raw(object):
__schema_example__ = None

def __init__(self, default=None, attribute=None, title=None, description=None,
required=None, readonly=None, example=None, mask=None, **kwargs):
required=None, readonly=None, example=None, mask=None,
dot_escape=False, **kwargs):
self.attribute = attribute
self.default = default
self.title = title
Expand All @@ -122,6 +150,7 @@ def __init__(self, default=None, attribute=None, title=None, description=None,
self.readonly = readonly
self.example = example or self.__schema_example__
self.mask = mask
self.dot_escape = dot_escape

def format(self, value):
'''
Expand Down Expand Up @@ -151,7 +180,11 @@ def output(self, key, obj, **kwargs):
:raises MarshallingError: In case of formatting problem
'''

value = get_value(key if self.attribute is None else self.attribute, obj)
value = get_value(
key if self.attribute is None else self.attribute,
obj,
dot_escape=self.dot_escape
)

if value is None:
default = self._v('default')
Expand Down
11 changes: 11 additions & 0 deletions flask_restplus/marshalling.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,15 @@ def _marshal(data, fields, envelope=None, skip_none=False, mask=None, ordered=Fa
>>> marshal(data, mfields, skip_none=True, ordered=True)
OrderedDict([('a', 100)])

>>> data = { 'a': 100, 'b.c': 'foo', 'd': None }
>>> mfields = {
'a': fields.Raw,
'b\.c': fields.Raw(dot_escape=True),
'd': fields.Raw
}

>>> marshal(data, mfields)
{'a': 100, 'b.c': 'foo', 'd': None}
"""
# ugly local import to avoid dependency loop
from .fields import Wildcard
Expand All @@ -171,6 +180,8 @@ def __format_field(key, val):
if isinstance(field, Wildcard):
has_wildcards['present'] = True
value = field.output(key, data, ordered=ordered)
if field.dot_escape:
key = key.replace("\.", ".")
return (key, value)

items = (
Expand Down
12 changes: 12 additions & 0 deletions tests/test_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,18 @@ def test_nested_object(self, mocker):
field = fields.Raw()
assert field.output('bar.value', foo) == 42

@pytest.mark.parametrize(
"foo,key,val",
[
({'not.nested.value': 42}, 'not\.nested\.value', 42),
({'semi': {'nested.value': 42}}, 'semi.nested\.value', 42),
({'completely': {'nested': {'value': 42}}}, 'completely.nested.value', 42),
]
)
def test_dot_escape(self, foo, key, val):
field = fields.Raw(dot_escape=True)
assert field.output(key, foo) == val


class StringFieldTest(StringTestMixin, BaseFieldTestMixin, FieldTestCase):
field_class = fields.String
Expand Down
40 changes: 40 additions & 0 deletions tests/test_marshalling.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,46 @@ def test_marshal_nested(self):

assert output == expected

def test_marshal_dot_escape(self):
model = {
'foo\.bar': fields.Raw(dot_escape=True),
'baz': fields.Raw,
}

marshal_fields = {
'foo.bar': 'bar',
'baz': 'foobar',
}
expected = {
'foo.bar': 'bar',
'baz': 'foobar',
}

output = marshal(marshal_fields, model)

assert output == expected

def test_marshal_dot_escape_partial(self):
model = {
'foo\.bar.baz': fields.Raw(dot_escape=True),
'bat': fields.Raw,
}

marshal_fields = {
'foo.bar': {
'baz': 'fee'
},
'bat': 'fye',
}
expected = {
'foo.bar.baz': 'fee',
'bat': 'fye',
}

output = marshal(marshal_fields, model)

assert output == expected

def test_marshal_nested_ordered(self):
model = OrderedDict([
('foo', fields.Raw),
Expand Down