Skip to content

Commit 8fe3526

Browse files
Merge pull request #276 from laws-africa/filter-facets
Add FacetedFilterSearchFilterBackend
2 parents 046bb61 + a07771d commit 8fe3526

File tree

9 files changed

+368
-2
lines changed

9 files changed

+368
-2
lines changed

docs/advanced_usage_examples.rst

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,68 @@ In the example below, we show results with faceted ``state`` and
754754
755755
http://127.0.0.1:8000/search/books/?facet=state&facet=pages_count
756756
757+
Faceted and filtered search
758+
---------------------------
759+
760+
It is sometimes useful to be able to facet and filter on the same field. For example, this would allow the user
761+
to apply a filter such as `size=medium` and a `size` facet, and get back a `size` facet that has all sizes, not
762+
just `medium`. This is similar to
763+
`elasticsearch-dsl's FacetedSearch class<https://elasticsearch-dsl.readthedocs.io/en/latest/faceted_search.html>`_.
764+
765+
To do this, use `FacetedFilterSearchFilterBackend` instead of both `FacetedSearchFilterBackend` and
766+
`FilteringFilterBackend`. It will apply both the filters and the facets. It uses exactly the same configuration
767+
as the two backends it replaces.
768+
769+
*search_indexes/viewsets/book.py*
770+
771+
.. code-block:: python
772+
773+
# ...
774+
775+
from django_elasticsearch_dsl_drf.filter_backends import (
776+
# ...
777+
FacetedFilterSearchFilterBackend,
778+
)
779+
780+
# ...
781+
782+
from elasticsearch_dsl import (
783+
DateHistogramFacet,
784+
RangeFacet,
785+
TermsFacet,
786+
)
787+
788+
# ...
789+
790+
class BookDocumentView(DocumentViewSet):
791+
"""The BookDocument view."""
792+
793+
# ...
794+
795+
filter_backends = [
796+
# ...
797+
FacetedFilterSearchFilterBackend,
798+
]
799+
800+
# ...
801+
802+
filter_fields = {
803+
'state': 'state.raw', # The filter and facet fields MUST both use the same elasticsearch field
804+
}
805+
806+
faceted_search_fields = {
807+
'state': 'state.raw', # The filter and facet fields MUST both use the same elasticsearch field
808+
}
809+
810+
# ...
811+
812+
In the example below, we show results with faceted `state` and the
813+
filter `state=published`.
814+
815+
.. code-block:: text
816+
817+
http://127.0.0.1:8000/search/books/?facet=state&state=published
818+
757819
Post-filter
758820
-----------
759821
The `post_filter` is very similar to the common filter. The only difference

docs/changelog.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ are used for versioning (schema follows below):
1515
0.3.4 to 0.4).
1616
- All backwards incompatible changes are mentioned in this document.
1717

18+
0.23
19+
----
20+
?
21+
22+
- Add `FacetedFilterSearchFilterBackend` that performs faceting and filtering
23+
together.
24+
1825
0.22.2
1926
----
2027
2021-08-29

examples/simple/search_indexes/urls.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
BookSimpleQueryStringBoostSearchFilterBackendDocumentViewSet,
2525
BookSimpleQueryStringSearchFilterBackendDocumentViewSet,
2626
BookSourceSearchBackendDocumentViewSet,
27+
FacetedFilteredBookDocumentViewSet,
2728
JournalDocumentViewSet,
2829
CityCompoundSearchBackendDocumentViewSet,
2930
CityDocumentViewSet,
@@ -206,6 +207,12 @@
206207
basename='bookdocument_simple_query_string_boost_search_backend'
207208
)
208209

210+
router.register(
211+
r'books-faceted-filtered',
212+
FacetedFilteredBookDocumentViewSet,
213+
basename='bookdocument_faceted_filtered'
214+
)
215+
209216
# **********************************************************
210217
# ************************* Cities *************************
211218
# **********************************************************

examples/simple/search_indexes/viewsets/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
'CityDocumentViewSet',
3636
'JournalDocumentViewSet',
3737
'FrontAddressDocumentViewSet',
38+
'FacetedFilteredBookDocumentViewSet',
3839
'LocationDocumentViewSet',
3940
'PublisherDocumentViewSet',
4041
'QueryFriendlyPaginationBookDocumentViewSet',

examples/simple/search_indexes/viewsets/book/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from .compound_search_boost import *
44
from .default import *
55
from .default_filter_lookup import *
6+
from .faceted_filtered import *
67
from .functional_suggester import *
78
from .ignore_index_errors import *
89
from .frontend import *
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from django_elasticsearch_dsl_drf.filter_backends import (
2+
DefaultOrderingFilterBackend,
3+
FacetedFilterSearchFilterBackend,
4+
)
5+
6+
from .base import BaseBookDocumentViewSet
7+
8+
9+
__all__ = (
10+
'FacetedFilteredBookDocumentViewSet',
11+
)
12+
13+
14+
class FacetedFilteredBookDocumentViewSet(BaseBookDocumentViewSet):
15+
"""The BookDocument view with faceted filtering."""
16+
17+
filter_backends = [
18+
FacetedFilterSearchFilterBackend,
19+
DefaultOrderingFilterBackend,
20+
]
21+
22+
filter_fields = {
23+
'title': 'title.raw',
24+
'state': 'state.raw',
25+
}
26+
27+
faceted_search_fields = {
28+
'state': 'state.raw',
29+
}

src/django_elasticsearch_dsl_drf/filter_backends/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
All filter backends.
33
"""
44

5-
from .faceted_search import FacetedSearchFilterBackend
5+
from .faceted_search import (
6+
FacetedSearchFilterBackend,
7+
FacetedFilterSearchFilterBackend
8+
)
69
from .filtering import (
710
FilteringFilterBackend,
811
GeoSpatialFilteringFilterBackend,

src/django_elasticsearch_dsl_drf/filter_backends/faceted_search.py

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
Faceted search backend.
33
"""
44
import copy
5+
from collections import defaultdict
6+
57
from elasticsearch_dsl import TermsFacet
68
from elasticsearch_dsl.query import Q
79

@@ -13,7 +15,9 @@
1315
__author__ = 'Artur Barseghyan <[email protected]>'
1416
__copyright__ = '2017-2020 Artur Barseghyan'
1517
__license__ = 'GPL 2.0/LGPL 2.1'
16-
__all__ = ('FacetedSearchFilterBackend',)
18+
__all__ = ('FacetedSearchFilterBackend', 'FacetedFilterSearchFilterBackend')
19+
20+
from django_elasticsearch_dsl_drf.filter_backends.filtering import FilteringFilterBackend
1721

1822

1923
class FacetedSearchFilterBackend(BaseFilterBackend):
@@ -229,3 +233,91 @@ def filter_queryset(self, request, queryset, view):
229233
:rtype: elasticsearch_dsl.search.Search
230234
"""
231235
return self.aggregate(request, queryset, view)
236+
237+
238+
class FacetedFilterSearchFilterBackend(FilteringFilterBackend, FacetedSearchFilterBackend):
239+
""" Combined faceting and filtering backend similar to elasticsearch-dsl's FacetedSearch class.
240+
It combines the functionality of FilteringFilterBackend and FacetedSearchFilterBackend to take filters into
241+
account when creating facets.
242+
243+
This backend uses the same configuration fields as FilteringFilterBackend and FacetedSearchFilterBackend.
244+
This backend replaces their functionality and should not be used together with either of those backends.
245+
246+
Note that to work correctly, the actual elasticsearch field must be the same for a facet and its matching filter.
247+
For example, if a facet will aggregate on field `state.raw`, then the filter must also filter on `state.raw`,
248+
and not just `state`.
249+
250+
When creating a facet, filters for faceted fields other than for the current facet are applied. Filters
251+
for faceted fields are then applied as post_filters. Filters on non-faceted fields are applied as normal filters.
252+
"""
253+
def filter_queryset(self, request, queryset, view):
254+
# the fact that apply_filter is a classmethod means we can't store state on self,
255+
# so we hitch it onto queryset
256+
queryset._facets = self.construct_facets(request, view)
257+
queryset._faceted_fields = set(f['facet']._params['field'] for f in queryset._facets.values())
258+
queryset._filters = defaultdict(list)
259+
260+
# apply filters
261+
queryset = FilteringFilterBackend.filter_queryset(self, request, queryset, view)
262+
263+
# apply aggregations
264+
return self.aggregate(request, queryset, view)
265+
266+
@classmethod
267+
def apply_filter(cls, queryset, options=None, args=None, kwargs=None):
268+
if args is None:
269+
args = []
270+
if kwargs is None:
271+
kwargs = {}
272+
273+
facets = queryset._facets
274+
faceted_fields = queryset._faceted_fields
275+
filters = queryset._filters
276+
277+
# if this field is faceted, then apply it as a post-filter
278+
if options['field'] in faceted_fields:
279+
queryset = queryset.post_filter(*args, **kwargs)
280+
else:
281+
queryset = queryset.filter(*args, **kwargs)
282+
283+
filters[options['field']].append(Q(*args, **kwargs))
284+
285+
# ensure the new queryset object retains the helper variables
286+
queryset._facets = facets
287+
queryset._faceted_fields = faceted_fields
288+
queryset._filters = filters
289+
return queryset
290+
291+
def aggregate(self, request, queryset, view):
292+
facets = queryset._facets
293+
faceted_fields = queryset._faceted_fields
294+
filters = queryset._filters
295+
296+
for field, facet in facets.items():
297+
agg = facet['facet'].get_aggregation()
298+
299+
if facet['global']:
300+
queryset.aggs.bucket(
301+
'_filter_' + field,
302+
'global'
303+
).bucket(field, agg)
304+
continue
305+
306+
agg_filter = Q('match_all')
307+
for f, _filter in filters.items():
308+
# apply filters for that are applicable for facets other than this one
309+
if agg.field == f or f not in faceted_fields:
310+
continue
311+
# combine with or
312+
q = _filter[0]
313+
for x in _filter[1:]:
314+
q = q | x
315+
agg_filter &= q
316+
317+
queryset.aggs.bucket(
318+
'_filter_' + field,
319+
'filter',
320+
filter=agg_filter
321+
).bucket(field, agg)
322+
323+
return queryset

0 commit comments

Comments
 (0)