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

Add optional support for django filters #72

Closed
wants to merge 75 commits into from

Conversation

TauPan
Copy link
Contributor

@TauPan TauPan commented Jan 30, 2020

While trying to use yadcf (https://github.com/vedmack/yadcf) for (date) range filters and multiselect-dropdowns I noticed in the resulting prototype that the queries where too slow for our database.

This is because icontains or iregex is always used, regardless of the type of field.

Instead of trying to make a specific backend for our application, I decided it would be worthwhile to try to integrate django-filters into django-rest-framework-datatables.

This is a complex task and it took me three (calendar) months to get to this point.

See https://github.com/TauPan/django-rest-framework-datatables/blob/add-django-filters/docs/django-filters.rst for documentation.

(It looks to me like the task is more or less finished, but I'm still integrating it into a project, so more fixes and modifications might come up.)

Notable points:

  • I did not modify the example app or create an example app for the django-filter integration. I did so in my first prototype, but it's not very useful as a demonstration, since the real point of using django-filters if you need to optimize things. And the dataset for the example app is very small, so there's no need for optimization here.

  • I had to drop python 2.7 support, since django-filter 2.2.0 requires python 3. See Planned timeline for python 2 support? #71

  • I had to refactor the main backend in order to keep things DRY.

  • The api and behaviour is a bit different than plain django-rest-framework-datatables. This is covered in the documentation under "Differences and Limitations".

I understand you don't have a lot of time to review things at the moment, so I'll just integrate this code into our django application and see how far I get and if there are problems or new use-cases, I'll update this pull request.

q objects have value semantics, see
https://docs.djangoproject.com/en/1.7/topics/db/queries/#complex-lookups-with-q-objects:

"When an operator is used on two Q objects, it yields a new Q object."
I don't think we'll use the workflow differently.
(only reported on python 2.7 for some reason)
Since rest_framework.backends.DjangoFilterBackend inherits from
DatatablesFilterBackend we get all the functionality without any code.

This tests will break as soon as we override filter_queryset or
inherit from a different class.
reimplemented based on DatatablesBaseFilterBackend
Python 2 is frozen.

Last release will be Python 2.7.18 in April 2020

(And yes, I'm having trouble supporting python 2 in this new code.)
So I can combine it with multiple versions of other stuff.
(oops... I was using pytest for so long that I didn't notice)
django-filter makes these things easy
I'm not sure if this is good... I just stumbled about sorting being
broken in my app, because I wasn't aware that the ordering is only
dependant on the name attribute.
@TauPan
Copy link
Contributor Author

TauPan commented Feb 7, 2020

I've managed to use the django filters based implementation as a drop-in replacement[1] for django-rest-framework-datatables (prior to this PR) but right now it requires that:

  • The data attribute contains the exact lookup (i.e. 'related_model__field')
  • The filter field_name should be exactly identical to that.
  • The fields on the serializer should also be exactly identical.

Only then will the combination of column-search and global search work correctly.

This is a bit more restrictive (and repetitive) than before, which is certainly unintended. I'm not sure what's the best way to resolve this. It does work, however ;)

[1] By this I mean that icontains and iregex is used at all times and there are no optimized queries so far whatsoever.

@TauPan
Copy link
Contributor Author

TauPan commented Feb 7, 2020

About that coverage decrease:

Impacted Files Coverage Δ
[...]
rest_framework_datatables/pagination.py 96.42% <0%> (-3.58%)

This is only because there's no ImportError any more, as django.utils.six is always imported. Maybe that's because I removed Python 2?

@TauPan
Copy link
Contributor Author

TauPan commented Feb 10, 2020

As an appetizer (see #26) , this is an example of a (working) filter for a numerical range from https://github.com/vedmack/yadcf/

class YADCFRangeWidget(widgets.RangeWidget):

    def value_from_datadict(self, data, files, name):
        if name not in data:
            return None
        vals = data[name].split('-yadcf_delim-')
        new_data = data.copy()
        del new_data[name]
        for val, suffix in zip(vals, self.suffixes):
            new_data[self.suffixed(name, suffix)] = val
        return super().value_from_datadict(new_data, files, name)


class YADCFRangeField(fields.RangeField):

    widget = YADCFRangeWidget


class YADCFRangeFilter(filters.RangeFilter):

    field_class = YADCFRangeField


class MyFilter(DatatablesFilterSet):
    range = YADCFRangeFilter()

(I wasn't sure if it's ok to modify data, otherwise value_from_datadict might be a lot shorter.)

This will generate queries like WHERE range BETWEEN 1 AND 10 ...

@TauPan
Copy link
Contributor Author

TauPan commented Mar 16, 2020

Also filtering datetime ranges works with very little code:

class YADCFDateTimeRangeWidget(YADCFRangeWidget):

    suffixes = ['after', 'before']


class YADCFIsoDateTimeRangeField(fields.IsoDateTimeRangeField):

    widget = YADCFDateTimeRangeWidget


class YADCFIsoDateTimeFromToRangeFilter(filters.IsoDateTimeFromToRangeFilter):

    field_class = YADCFIsoDateTimeRangeField

(preferably with eonasdan-bootstrap-datetimepicker ... not sure if others will work)

Well, we're cheating a bit, since the filterset still calls the
classmethod replace_last_lookup directly.
This completes fixing a small design issue I encountered while
adopting my code to this new API.
@TauPan
Copy link
Contributor Author

TauPan commented Mar 19, 2020

Ok... I've noticed a problem in my code and fixed it above. The problem is that if I want to use a different lookup, e.g. 'in' for efficiency reasons, I need to override it in my filter, because the filterset will blatantly override everything with 'iregex' if [search_regex] is set on the column.

Part of the problem is that YADCF will always set search_regex e.g. on a multiselect filter.

So the responsibility to switch lookups must be on the filter class, not the filterset directly.

Now if you want to switch your lookups, you need to mixin SwitchRegexFilter, which comes with the added bonus that you can set lookup_regex_replacements to a different mapping on your filter.

(This means you could switch between arbitrary lookup types by toggling the regex feature on the datatables column.)

I guess I need to document this. I'll do that after some more testing.

@TauPan
Copy link
Contributor Author

TauPan commented Mar 20, 2020

The limitation that the 'data' attribute needs to be set to the exact lookup (e.g. my_relation__field) in order for global search to work is caused by the way I construct the global search lookup from the datatables query.

The GlobalFilter mixin could be a little smarter and look at Filter-Object for the correct way to construct a global lookup.

The problem was that I checked the 'field_name' attribute, which is
the name of the actual field on the model, instead of the name of the
filter.

This was forcing me to pick the names of the filters identical to the
field names, including full lookups, otherwise e.g. the regex flag
would not propagate.
This is what we need to get to the filter field, the name attribute is
useless for us, as the filter already defines the lookup.

Also the 'name' is meant to be just an identifier on the Datatable and
we don't need to add any magic property to it with a django-filter
based backend. That's what the filters are for, really.

See:

 - https://datatables.net/reference/option/columns.data
 - https://datatables.net/reference/option/columns.name
 - https://datatables.net/manual/server-side
The column search was dispatched on the filter name, but the global
search wasn't yet... :-S
@TauPan
Copy link
Contributor Author

TauPan commented Mar 24, 2020

By now I've fixed all three of those issues. If you know django-filters, the filters will work as expected without any special magic and my code will infer the correct ordering based on the filter configuration.

This follows the principle of least astonishment if you come from django-filters, however it behaves quite differently compared with django-rest-framework-datatables without django-filter support. I've documented the differences in "Differences and limitations".

This feels like the correct design choice, since filter configuration via django-filters is way more flexible and powerful and it also means that django-rest-framework-datatables will behave more like vanilla Datatables (without any special magic behaviour of column name attributes).

I've managed to use the django filters based implementation as a drop-in replacement[1] for django-rest-framework-datatables (prior to this PR) but right now it requires that:

  • The data attribute contains the exact lookup (i.e. 'related_model__field')
  • The filter field_name should be exactly identical to that.
  • The fields on the serializer should also be exactly identical.

Only then will the combination of column-search and global search work correctly.

This is a bit more restrictive (and repetitive) than before, which is certainly unintended. I'm not sure what's the best way to resolve this. It does work, however ;)

[1] By this I mean that icontains and iregex is used at all times and there are no optimized queries so far whatsoever.

@izimobil
Copy link
Owner

@TauPan Really good work, I will merge this soon, but I want to do a bugfix release before that.

Copy link
Owner

@izimobil izimobil left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will fix the conflicts and merge manually.
We'll improve doc examples later. Thank @TauPan !

serializer_class = AlbumSerializer
filter_backends = (DatatablesFilterBackend,)
filterset_class = AlbumGlobalFilter

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be great to add a HTML/JS example here.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please do it?
I'm a bit lost on how to do this. :(

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see #79

@izimobil
Copy link
Owner

@TauPan : I've fixed the conflicts and one issue (with order_by), all tests pass ! You PR is now merged, but for some reason (I did the merge locally), your commits do not appear in the commit log, really sorry about that !

@TauPan
Copy link
Contributor Author

TauPan commented Apr 14, 2020

@TauPan : I've fixed the conflicts and one issue (with order_by), all tests pass ! You PR is now merged,

Good news! :)

Can you elaborate on which issue you did fix with order_by? Is there a testcase for that? Since you put everything into one large commit (understandable, since you had to rebase), it's a bit hard to see what changed in comparison with my branch.

but for some reason (I did the merge locally), your commits do not appear in the commit log, really sorry about that !

That's no issue at all :) Thanks for merging!

@izimobil
Copy link
Owner

The queryset was always ordered, even when get_ordering() returned an empty list, and this was causing warnings when running the test cases:

/home/izi/VirtualEnvs/drf-datatables/django-rest-framework-datatables/rest_framework_datatables/pagination.py:69: UnorderedObjectListWarning: Pagination may yield inconsistent results with an unordered object_list: <class 'albums.models.Album'> QuerySet.
  super(CachedCountPaginator, self).__init__(*args, **kwargs)

Not a big deal :) Thanks again.

@TauPan TauPan deleted the add-django-filters branch September 17, 2021 12:05
@TauPan TauPan restored the add-django-filters branch September 30, 2021 11:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants