Skip to content

Commit

Permalink
Allows filtering before function evaluation.
Browse files Browse the repository at this point in the history
  • Loading branch information
jfinkels committed Apr 27, 2016
1 parent 4538e1a commit 41a225e
Show file tree
Hide file tree
Showing 9 changed files with 293 additions and 181 deletions.
2 changes: 1 addition & 1 deletion CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Version 1.0.0b2-dev

Not yet released.

No changes yet.
- :issue:`7`: allows filtering before function evaluation.

Version 1.0.0b1
---------------
Expand Down
9 changes: 9 additions & 0 deletions docs/fetching.rst
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,15 @@ yields the response
"data": [42]
}

The function evaluation endpoint also respects filtering query
parameters. Specifically, filters are applied to the model *before* the
function evaluation is performed, so you can apply a function to a subset of
resources. See :ref:`filtering` for more information.

.. versionchanged:: 1.0.0b2

Adds ability to use filters in function evaluation.

.. _includes:

Inclusion of related resources
Expand Down
66 changes: 59 additions & 7 deletions flask_restless/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,22 @@ class ComparisonToNull(Exception):
pass


class FilterCreationError(Exception):
"""Raised when there is a problem creating a SQLAlchemy filter object.
`message` is a string providing detailed information about the cause
of the problem.
"""

def __init__(self, message, *args, **kw):
super(FilterCreationError, self).__init__(*args, **kw)
self._message = message

def message(self):
return self._message


class UnknownField(Exception):
"""Raised when the user attempts to reference a field that does not
exist on a model in a search.
Expand Down Expand Up @@ -326,9 +342,9 @@ def create_filter(model, filt):
`filt` is an instance of the :class:`Filter` class.
Raises one of :exc:`AttributeError`, :exc:`KeyError`, or
:exc:`TypeError` if there is a problem creating the query. See the
documentation for :func:`create_operation` for more information.
Raises a :exc:`.FilterCreationError` if there is a problem creating
the query; see the documentation for :func:`create_operation` for
more information on possible causes of such an error.
"""
# If the filter is not a conjunction or a disjunction, simply proceed
Expand All @@ -339,15 +355,51 @@ def create_filter(model, filt):
# get the other field to which to compare, if it exists
if filt.otherfield:
val = getattr(model, filt.otherfield)
# for the sake of brevity...
return create_operation(model, fname, filt.operator, val)
try:
return create_operation(model, fname, filt.operator, val)
except KeyError:
message = 'unknown operator {0}'.format(filt.operator)
raise FilterCreationError(message)
except TypeError:
message = ('incorrect number of arguments provided for operation'
' {0}').format(filt.operator)
raise FilterCreationError(message)
except AttributeError:
if not filt.otherfield:
message = ('no column with name "{0}" exists on model'
' {1}').format(fname, model)
else:
message = ('no column with name either "{0}" or "{1}" exists'
' on model {1}').format(fname, val, model)
raise FilterCreationError(message)
# Otherwise, if this filter is a conjunction or a disjunction, make
# sure to apply the appropriate filter operation.
if isinstance(filt, ConjunctionFilter):
return and_(create_filter(model, f) for f in filt)
return or_(create_filter(model, f) for f in filt)


def create_filters(model, filters):
"""Creates a list of SQLAlchemy filter objects, ready to be provided as
the positional arguments in an invocation of
:meth:`sqlalchemy.orm.Query.filter`.
`model` is the SQLAlchemy model on which the filters will be
applied.
`filters` is an iterable of dictionaries representing filter
objects, as described in the Flask-Restless documentation
(:ref:`filtering`).
This function may raise :exc:`FilterCreationError` if there is a
problem converting one of the filters into a SQLAlchemy object.
"""
filters = [Filter.from_dictionary(model, f) for f in filters]
# Each of these function calls may raise a FilterCreationError.
return [create_filter(model, f) for f in filters]


def search_relationship(session, instance, relation, filters=None, sort=None,
group_by=None):
model = get_model(instance)
Expand Down Expand Up @@ -411,9 +463,9 @@ def search(session, model, filters=None, sort=None, group_by=None,
query = session_query(session, model)

# Filter the query.
filters = [Filter.from_dictionary(model, f) for f in filters]
#
# This function call may raise an exception.
filters = [create_filter(model, f) for f in filters]
filters = create_filters(model, filters)
query = query.filter(*filters)

# Order the query. If no order field is specified, order by primary
Expand Down
129 changes: 65 additions & 64 deletions flask_restless/views/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,7 @@
from ..search import search
from ..search import search_relationship
from ..search import UnknownField
from ..serialization import simple_serialize
from ..serialization import simple_relationship_serialize
from ..serialization import DefaultDeserializer
from ..serialization import DeserializationException
from ..serialization import SerializationException
from .helpers import count
Expand Down Expand Up @@ -803,6 +801,71 @@ def errors_from_serialization_exceptions(exceptions, included=False):
return errors_response(500, errors)


def collection_parameters():
"""Gets filtering, sorting, grouping, and other settings from the
request that affect the collection of resources in a response.
Returns a four-tuple of the form ``(filters, sort, group_by,
single)``. These can be provided to the
:func:`~flask_restless.search.search` function; for more
information, see the documentation for that function.
This function can only be invoked in a request context.
"""
# Determine filtering options.
filters = json.loads(request.args.get(FILTER_PARAM, '[]'))
# # TODO fix this using the below
# filters = [strings_to_dates(self.model, f) for f in filters]

# # resolve date-strings as required by the model
# for param in search_params.get('filters', list()):
# if 'name' in param and 'val' in param:
# query_model = self.model
# query_field = param['name']
# if '__' in param['name']:
# fieldname, relation = param['name'].split('__')
# submodel = getattr(self.model, fieldname)
# if isinstance(submodel, InstrumentedAttribute):
# query_model = submodel.property.mapper.class_
# query_field = relation
# elif isinstance(submodel, AssociationProxy):
# # For the sake of brevity, rename this function.
# get_assoc = get_related_association_proxy_model
# query_model = get_assoc(submodel)
# query_field = relation
# to_convert = {query_field: param['val']}
# try:
# result = strings_to_dates(query_model, to_convert)
# except ValueError as exception:
# current_app.logger.exception(str(exception))
# return dict(message='Unable to construct query'), 400
# param['val'] = result.get(query_field)

# Determine sorting options.
sort = request.args.get(SORT_PARAM)
if sort:
sort = [('-', value[1:]) if value.startswith('-') else ('+', value)
for value in sort.split(',')]
else:
sort = []

# Determine grouping options.
group_by = request.args.get(GROUP_PARAM)
if group_by:
group_by = group_by.split(',')
else:
group_by = []

# Determine whether the client expects a single resource response.
try:
single = bool(int(request.args.get('filter[single]', 0)))
except ValueError:
raise SingleKeyError('failed to extract Boolean from parameter')

return filters, sort, group_by, single


#: Creates the mimerender object necessary for decorating responses with a
#: function that automatically formats the dictionary in the appropriate format
#: based on the ``Accept`` header.
Expand Down Expand Up @@ -1329,68 +1392,6 @@ def get_all_inclusions(self, instance_or_instances):
# serializing the included resources.
return self._serialize_many(to_include)

def _collection_parameters(self):
"""Gets filtering, sorting, grouping, and other settings from the
request that affect the collection of resources in a response.
Returns a four-tuple of the form ``(filters, sort, group_by,
single)``. These can be provided to the
:func:`~flask_restless.search.search` function; for more
information, see the documentation for that function.
"""
# Determine filtering options.
filters = json.loads(request.args.get(FILTER_PARAM, '[]'))
# # TODO fix this using the below
# filters = [strings_to_dates(self.model, f) for f in filters]

# # resolve date-strings as required by the model
# for param in search_params.get('filters', list()):
# if 'name' in param and 'val' in param:
# query_model = self.model
# query_field = param['name']
# if '__' in param['name']:
# fieldname, relation = param['name'].split('__')
# submodel = getattr(self.model, fieldname)
# if isinstance(submodel, InstrumentedAttribute):
# query_model = submodel.property.mapper.class_
# query_field = relation
# elif isinstance(submodel, AssociationProxy):
# # For the sake of brevity, rename this function.
# get_assoc = get_related_association_proxy_model
# query_model = get_assoc(submodel)
# query_field = relation
# to_convert = {query_field: param['val']}
# try:
# result = strings_to_dates(query_model, to_convert)
# except ValueError as exception:
# current_app.logger.exception(str(exception))
# return dict(message='Unable to construct query'), 400
# param['val'] = result.get(query_field)

# Determine sorting options.
sort = request.args.get(SORT_PARAM)
if sort:
sort = [('-', value[1:]) if value.startswith('-') else ('+', value)
for value in sort.split(',')]
else:
sort = []

# Determine grouping options.
group_by = request.args.get(GROUP_PARAM)
if group_by:
group_by = group_by.split(',')
else:
group_by = []

# Determine whether the client expects a single resource response.
try:
single = bool(int(request.args.get('filter[single]', 0)))
except ValueError:
raise SingleKeyError('failed to extract Boolean from parameter')

return filters, sort, group_by, single

def _paginated(self, items, filters=None, sort=None, group_by=None):
"""Returns a :class:`Paginated` object representing the
correctly paginated list of resources to return to the client,
Expand Down
Loading

0 comments on commit 41a225e

Please sign in to comment.