Skip to content
New issue

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

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

Already on GitHub? # to your account

ModelMultipleChoiceFilter's custom method gets called even when field is not filtered on #1625

Open
carltongibson opened this issue Nov 15, 2023 Discussed in #1622 · 11 comments

Comments

@carltongibson
Copy link
Owner

carltongibson commented Nov 15, 2023

Discussed in #1622

Converting to an issue to investigate.

Originally posted by johncmacy November 10, 2023
Hi,

I have a FilterSet with a ModelMultipleChoiceFilter that has a custom method:

class Filters(filters.FilterSet):
    publisher = filters.ModelMultipleChoiceFilter(
        queryset=Publisher.objects.all(),
        method="publisher_filter",
    )

    def publisher_filter(self, queryset, name, value):
        return queryset.filter(publisher__in=value)        # contrived example, we have more complex business logic

After getting some unexpected results, I found out that the custom method is getting called even when the field is not passed as a query param. For example, requests to /api/book return an empty list [].

After digging into it a bit, it seems that every filter gets called regardless if it's passed as a query param. Normally this is fine because they get an empty value and it doesn't apply any filtering. However, when a custom method is supplied, it gets called with <QuerySet []> as the value. So in the example above, it filters books by publisher in [], which results in the empty result set.

I got around it with:

    def publisher_filter(self, queryset, name, value):
        if not value:
            return queryset
        return queryset.filter(publisher__in=value)

However, I didn't expect the method to get called in the first place, since the request didn't have a "publisher" query param. Am I missing something in the filter configuration that would prevent it from being called in the first place?

Thanks!

@johncmacy
Copy link

johncmacy commented Nov 15, 2023

Continuing from #1622

Thanks Carlton.

Filters are called if the field is present in filterset.form.cleaned_data.

It's not expected though...

Note that the value is validated by the Filter.field, so raw value transformation and empty value checking should be unnecessary.
-- https://django-filter.readthedocs.io/en/stable/ref/filters.html#method

In my case, every filter on the filterset is present in cleaned_data. CharFilter, ModelChoiceFilter, etc. have their default empty value ("", None, etc.). However, for the ModelMultipleChoiceFilter that was not submitted, its value in cleaned_data is <QuerySet []>, which is not in EMPTY_VALUES and so it bypasses this if statement:

class Filter:
    def filter(self, qs, value):
        if value in EMPTY_VALUES:
            return qs

After further digging, it appears that filters with and without custom methods are behaving the same way (showing up in cleaned_data with a value of <QuerySet []>), so apparently it has nothing to do with custom methods as my original post indicated.

I'd need to dig in to see exactly why non-submitted fields are showing there.

To me, this is the issue. I ran into a similar problem when implementing an empty values filter. If it can be updated so that non-submitted filters do not show up in cleaned_data, that would be ideal. Or, update filter_queryset to only look at filters that were submitted:

class BaseFilterSet:
    def filter_queryset(self, queryset):
        for name in self.form.cleaned_data.keys() & self.request.query_params.keys():
            value = self.form.cleaned_data[name]
            queryset = self.filters[name].filter(queryset, value)
            ...
        return queryset

@johncmacy
Copy link

Update:

changed_data doesn't include submitted fields that have empty values:

for name in self.form.changed_data:

request.query_params and form.data could include query params that are not filters (and therefore not in cleaned_data:

for name in self.request.query_params:

for name in self.form.data:

So it seems we need to use a subset of keys that are in both:

for name in self.form.cleaned_data.keys() & self.request.query_params.keys():

for name in self.form.cleaned_data.keys() & self.form.data.keys():

@carltongibson
Copy link
Owner Author

changed_data doesn't include submitted fields that have empty values

So that's what I'm expecting (without cracking open the debugger).

Can you put the failing case in a test case against Django-filter's test suite? That makes it much easier to see exactly what you mean, rather than prose descriptions, where I can't see 100% what's going on.
Thanks!

@johncmacy
Copy link

Understood - yes, I'll do my best to write something using your test suite.

In the meantime, I have an example repo with tests that demonstrate the issue, if that's helpful.

@craigds
Copy link
Contributor

craigds commented Mar 12, 2024

I noticed something similar in a failing test in our app just now when upgrading from django-filter 23.2 to 23.3 - a callable choices on a ChoiceFilter (which wasn't used in the request) is now getting called when it wasn't before.

I suspect 6119d45 but the diff looks fine to me. For reference we're (still) on Django 3.2.x so the 5.x specific changes in that commit shouldn't apply to us

@carltongibson
Copy link
Owner Author

@craigds A test case against django-filter's test suite would be handy.

@carltongibson
Copy link
Owner Author

@johncmacy This may be resolved in the new v24.2. If you'd like to give that a run and confirm that would be great.

@johncmacy
Copy link

Hi @carltongibson, I upgraded to v24.2, and am still getting the same behavior as before.

@carltongibson
Copy link
Owner Author

@johncmacy Thanks for confirming. It's not the same issue then ✅

@carltongibson
Copy link
Owner Author

@johncmacy Just re-reading this, I still need some time to sit down with the debugger, but if I were writing this myself I'd would automatically handle the none value before filtering, so I'm suspecting the answer is likely to be That's just how it works.

@johncmacy
Copy link

Ok, thanks for following up on this @carltongibson. It's not a deal-breaker, we can work around it.

# for free to join this conversation on GitHub. Already have an account? # to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants