From 95c0421a923586fcc0dd135a2db6d7dd8dedd9c7 Mon Sep 17 00:00:00 2001 From: Steve Lacey Date: Fri, 22 Oct 2021 17:44:05 +0700 Subject: [PATCH] Switch search fields to list; closes #48 --- README.md | 40 ++++++++++-------------- tests/views.py | 12 +++---- worf/views/list.py | 78 ++++++++++++++++++++++++++-------------------- 3 files changed, 66 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 96ecc11..f7c9ffd 100644 --- a/README.md +++ b/README.md @@ -158,12 +158,12 @@ Class Attributes |name | required | type | description | | -- | -- | -- | -- | -|`bundle_name` | no | `str` | _Default:_ `None` If set, the returned data will use this name. I.e., {`bundle_name`: return_data}| -|`model`| yes | `class` | An uninstantiated `django.db.models.model` class. | -|`permissions`| yes | `list` of permissions classes | Will return appropriate HTTP status code based on the definition of the permission class. -| | | | | +|`bundle_name` | no | `str` | _Default:_ `None` If set, the returned data will use this name. I.e., {`bundle_name`: return_data} | +|`model` | yes | `class` | An uninstantiated `django.db.models.model` class. | +|`permissions` | yes | `list` of permissions classes | Will return appropriate HTTP status code based on the definition of the permission class. | ##### HTTP Methods + - GET is always supported. @@ -171,28 +171,23 @@ Class Attributes |name | required | type | description | | -- | -- | -- | -- | -|`api_method` | no | `str` | _Default:_ `api`. Must refer to a `model` method. This method is used to return data for `GET` requests. | -|`payload_key` | no | `str` | _Default:_ `model._meta.verbose_name_plural`. Use in order to rename the key for the results array | -|`filters` | no | `dict` | _Default:_ `{}`. Pass key/value pairs that you wish to further filter the queryset beyond the `lookup_url_kwarg` | -|`lookup_field` | no | `str` | Use these two settings in tandem in order to filter `get_queryset` based on a URL field. `lookup_url_kwarg` is required if this is set. | -|`lookup_url_kwarg`| no | `str` | Use these two settings in tandem in order to filter `get_queryset` based on a URL field. `lookup_field` is required if this is set. | -|`ordering` | no | `list` | _Default_: `[]`. Pass a list of fields to default the queryset order by. | -|`filter_fields` | no | `list` | _Default_: `None`. Pass a list of fields to support filtering via query params. | -|`search_fields` | no | `dict` or `bool` | _Default_: `None`. Pass a dict with `and`/`or` fields to search via the `q` query param. | -|`sort_fields` | no | `list` | _Default_: `None`. Pass a list of fields to support sorting via the `sort` query param. | -|`per_page` | no | `int` | _Default_: 25. Sets the number of results returned for each page. | -|`max_per_page` | no | `int` | _Default_: Same as `per_page`. Sets the max number of results to allow when passing the `perPage` query param. | -| | | | | +|`api_method` | no | `str` | _Default:_ `api`. Must refer to a `model` method. This method is used to return data for `GET` requests. | +|`payload_key` | no | `str` | _Default:_ `model._meta.verbose_name_plural`. Use in order to rename the key for the results array | +|`filters` | no | `dict` | _Default:_ `{}`. Pass key/value pairs that you wish to further filter the queryset beyond the `lookup_url_kwarg` | +|`lookup_field` | no | `str` | Use these two settings in tandem in order to filter `get_queryset` based on a URL field. `lookup_url_kwarg` is required if this is set. | +|`lookup_url_kwarg` | no | `str` | Use these two settings in tandem in order to filter `get_queryset` based on a URL field. `lookup_field` is required if this is set. | +|`ordering` | no | `list` | _Default_: `[]`. Pass a list of fields to default the queryset order by. | +|`filter_fields` | no | `list` | _Default_: `[]`. Pass a list of fields to support filtering via query params. | +|`search_fields` | no | `list` | _Default_: `[]`. Pass a list of fields to full text search via the `q` query param. | +|`sort_fields` | no | `list` | _Default_: `[]`. Pass a list of fields to support sorting via the `sort` query param. | +|`per_page` | no | `int` | _Default_: 25. Sets the number of results returned for each page. | +|`max_per_page` | no | `int` | _Default_: Same as `per_page`. Sets the max number of results to allow when passing the `perPage` query param. | ##### Search -Setting `search_fields` to `True` will enable search based on url parameters. -Parameters in the URL must be camelCase and exactly match the snake_case model -field. +Parameters in the URL must be camelCase and exactly match the snake_case model field. -To allow full text search, set to a dictionary of two lists: 'or' & 'and'. Each -list will be assembled using either `Q.OR` or `Q.AND`. Fields in each list -must be composed of django filter lookup argument names. +To allow full text search, set to a list of fields for django filter lookups. ##### Pagination @@ -233,7 +228,6 @@ properly. Do this by performing whatever logic you need to do, then update the |`api_method` | `api` | `str` | Must refer to a `model` method. This method is used to return data for `GET` requests. Additionally, `{api_method}_update_fields` will be called to execute `PATCH` requests.| |`lookup_field`| `id` | `str` | Override with the lookup field used to filter the _model_. Defaults to `id`| |`lookup_url_kwarg`| `id` | `str` | Override with the name of the parameter passed to the view by the URL route. Defaults to `id` | -| | | | | ##### `get_instance()` method This method uses `lookup_field` and `lookup_url_kwargs` to return a model instance. diff --git a/tests/views.py b/tests/views.py index 1c9c4df..478429d 100644 --- a/tests/views.py +++ b/tests/views.py @@ -25,7 +25,7 @@ class ProfileList(ListAPI): ordering = ["pk"] serializer = ProfileSerializer permissions = [PublicEndpoint] - search_fields = {} + search_fields = [] filter_fields = [ "tags", ] @@ -42,17 +42,15 @@ class UserList(ListAPI): ordering = ["pk"] serializer = UserSerializer permissions = [PublicEndpoint] - search_fields = { - "or": [ - "email", - "username", - ] - } filter_fields = [ "email", "date_joined__gte", "date_joined__lte", ] + search_fields = [ + "email", + "username", + ] sort_fields = [ "id", "date_joined", diff --git a/worf/views/list.py b/worf/views/list.py index a537a6d..b7a7b35 100644 --- a/worf/views/list.py +++ b/worf/views/list.py @@ -1,3 +1,7 @@ +import operator +from functools import reduce +import warnings + from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.core.paginator import Paginator, EmptyPage @@ -14,11 +18,10 @@ class ListAPI(AbstractBaseAPI): lookup_url_kwarg = "id" # default incase lookup_field is set filters = {} ordering = [] - filter_fields = None - search_fields = None - sort_fields = None + filter_fields = [] + search_fields = [] + sort_fields = [] queryset = None - q_objects = Q() filter_set = None count = 0 page_num = 1 @@ -31,25 +34,35 @@ def get(self, request, *args, **kwargs): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + + codepath = self.codepath + if not isinstance(self.filters, dict): - raise ImproperlyConfigured(f"{self.codepath}.filters must be type: dict") + raise ImproperlyConfigured(f"{codepath}.filters must be type: dict") + if not isinstance(self.ordering, list): - raise ImproperlyConfigured(f"{self.codepath}.ordering must be type: list") - if self.filter_fields is not None and not isinstance(self.filter_fields, list): - raise ImproperlyConfigured( - f"{self.codepath}.filter_fields must be type: list" - ) - if self.search_fields is not None and not isinstance(self.search_fields, dict): - raise ImproperlyConfigured( - f"{self.codepath}.search_fields must be type: dict" - ) - if self.sort_fields is not None and not isinstance(self.sort_fields, list): - raise ImproperlyConfigured( - f"{self.codepath}.sort_fields must be type: list" - ) + raise ImproperlyConfigured(f"{codepath}.ordering must be type: list") + + if not isinstance(self.filter_fields, list): + raise ImproperlyConfigured(f"{codepath}.filter_fields must be type: list") + + if not isinstance(self.search_fields, (dict, list)): + raise ImproperlyConfigured(f"{codepath}.search_fields must be type: list") + + if not isinstance(self.sort_fields, list): + raise ImproperlyConfigured(f"{codepath}.sort_fields must be type: list") + + # generate a default filterset if a custom one was not provided if self.filter_set is None: self.filter_set = generate_filterset(self.model) + # support deprecated search_fields and/or dict syntax (note that `and` does nothing) + if isinstance(self.search_fields, dict): + warnings.warn( + f"Passing a dict to {codepath}.search_fields is deprecated. Pass a list instead." + ) + self.search_fields = self.search_fields.get("or", []) + def _set_base_lookup_kwargs(self): # Filters set directly on the class self.lookup_kwargs.update(self.filters) @@ -69,27 +82,24 @@ def set_search_lookup_kwargs(self): For more advanced search use cases, override this method and pass GET with any remaining params you want to use classic django filters for. """ - if self.search_fields is None: - """If self.search_fields is not set, we don't allow search.""" + if not self.filter_fields and not self.search_fields: return self.set_bundle_from_querystring() + # Whatever is not q or page as a querystring param will # be used for key-value search. - search_string = self.bundle.get("q", False) + query = self.bundle.pop("q", "").strip() + self.bundle.pop("page", None) self.bundle.pop("p", None) - self.bundle.pop("q", None) - if search_string and len(search_string): - the_ors = self.search_fields.get("or", []) - for attr in the_ors: - kwarg = {attr + "__icontains": search_string.strip()} - self.q_objects.add(Q(**kwarg), Q.OR) - - the_ands = self.search_fields.get("ands", []) - for attr in the_ands: - kwarg = {attr + "__icontains": search_string.strip()} + if query: + search_icontains = ( + Q(**{f"{search_field}__icontains": query}) + for search_field in self.search_fields + ) + self.search_query = reduce(operator.or_, search_icontains) if not self.filter_fields or not self.bundle: return @@ -131,7 +141,7 @@ def get_processed_queryset(self): queryset = self.get_queryset() self.lookup_kwargs = {} - self.q_objects = Q() + self.search_query = Q() self._set_base_lookup_kwargs() self.set_search_lookup_kwargs() @@ -145,7 +155,7 @@ def get_processed_queryset(self): try: queryset = ( apply_filterset(self.filter_set, queryset, self.lookup_kwargs) - .filter(self.q_objects) + .filter(self.search_query) .order_by(*order_by) .distinct() ) @@ -229,7 +239,7 @@ def serialize(self): "bundle": self.bundle, "lookup_kwargs": self.lookup_kwargs, "query": self.query, - "q_objs": str(self.q_objects), + "search_query": str(self.search_query), "serializer": str(serializer), } }