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

Invalid result in many to many list if through instance is soft deleted #172

Open
RasoulRostami opened this issue Oct 9, 2021 · 10 comments
Labels

Comments

@RasoulRostami
Copy link

Hi guys. I have below models

class BaseModel(SafeDeleteModel):
     _safedelete_policy = SOFT_DELETE_CASCADE
     
class Student(BaseModel):
    full_name = models.CharField(max_length=150)

class School(BaseModel):
    name = models.CharField(max_length=150)
    students = models.ManyToManyField(
        Student,
        through='SchoolStudent',
    )

class SchoolStudent(BaseModel):
    student = models.ForeignKey(Student, on_delete=models.CASCADE)
    school = models.ForeignKey(School, on_delete=models.CASCADE)

I create a relationship between a school and a student, and then I delete it. but the below event occurs

school = School.objects.create(name='new_school')
student = Student.objects.create(full_name='Tom Smith')
school.students.add(student)
# delete all relationship
SchoolStudent.objects.all().delete()

school.students.all()

<QuerySet [<Student: Tom Smith>]>

As you see. we get wrong results. we deleted the relationship but we get the queryset.
Have you got any idea to solve the issue?

@Gagaro
Copy link
Member

Gagaro commented Oct 11, 2021

Hi, was it with the 1.1.0? Filtering was moved to the query itself to fix those kind of things.

@bjudson
Copy link

bjudson commented Oct 13, 2021

I'm seeing this same issue with 1.1.0. I actually was working on my own soft delete implementation and this was one of the issues that led me to start looking at 3rd party libraries. What I've found is that RelatedManager inherits the way you'd expect, but ManyRelatedManager does not. So you'll find with an example like above:

school.students.count()  # returns 1
school.schoolstudent_set.count()  # returns 0

@RasoulRostami
Copy link
Author

Hi, was it with the 1.1.0? Filtering was moved to the query itself to fix those kind of things.

Thanks for your response.
Yes. It was the 1.1.0 version. I have checked it to have the newer version.

@RasoulRostami
Copy link
Author

I'm seeing this same issue with 1.1.0. I actually was working on my own soft delete implementation and this was one of the issues that led me to start looking at 3rd party libraries. What I've found is that RelatedManager inherits the way you'd expect, but ManyRelatedManager does not. So you'll find with an example like above:

school.students.count()  # returns 1
school.schoolstudent_set.count()  # returns 0

Thanks for your response.
It worked, but as you know, it returns the SchoolStudent entities. but I need student entities.

@Gagaro
Copy link
Member

Gagaro commented Oct 18, 2021

It's probably because the through instance is deleted but not the related one. I'll try to see if I can find a quick fix,

@Gagaro Gagaro changed the title Invalid result in many to many list Invalid result in many to many list if through instance is soft deleted Oct 18, 2021
@Gagaro Gagaro added the bug label Oct 18, 2021
@Gagaro
Copy link
Member

Gagaro commented Oct 18, 2021

So because create_forward_many_to_many_manager() create a new manager inheriting from our own and not the other way around, it seems complicated to fix this.

I added a test which reproduce this but it fails for now.

@bjudson
Copy link

bjudson commented Oct 18, 2021

I'm seeing this same issue with 1.1.0. I actually was working on my own soft delete implementation and this was one of the issues that led me to start looking at 3rd party libraries. What I've found is that RelatedManager inherits the way you'd expect, but ManyRelatedManager does not. So you'll find with an example like above:

school.students.count()  # returns 1
school.schoolstudent_set.count()  # returns 0

Thanks for your response. It worked, but as you know, it returns the SchoolStudent entities. but I need student entities.

Correct. As a workaround for your situation, you can just do something like:

Student.objects.filter(schoolstudent__school=school, schoolstudent__deleted=None)

But it will be difficult to remember not to use ManyRelatedManager with these objects, and likely lead to bugs at some point.

I'll keep looking at this and see if I can find any solution, and if so submit a PR. I decided to adopt this library in a project I'm working on, largely because of its strong tests and handling of tricky situations like this, so I'd love to have this one handled in an intuitive way.

Thanks for your work & responsiveness @Gagaro

@charles-hussey
Copy link

A bit late to the party, just chiming in here since I encountered the same issue. Unfortunately, I don't see a way to easily integrate a fix for this into the current code base, since the problem is due to the ManyRelatedManager bypassing the manager for through models. But a customizable solution that worked for me was just to superclass the get_queryset method on the managers for the models at both ends of the m2m field. So for your example it would look like:

class StudentManager(SafeDeleteManager):
    def get_queryset(self):
        through = getattr(self, 'through', None)
        if through and issubclass(through, SafeDeleteModel):
            self.core_filters.update({
                "schoolstudent__deleted__is_null": True
            })
        return super().get_queryset()

And the manager for the School class would look much the same. I dunno, lmk if this is poor Django form.

I'm on Django 4.1.1, in case that matters

@nathanbottomley
Copy link

nathanbottomley commented Dec 8, 2023

I'm also seeing the same behaviour. As far as I can tell this makes the safedelete package basically incompatible with m2m relationships with through models?

At the moment we have been able to get something that works by generalising @charles-hussey's solution so that it can handle being passed to all models, and with any custom foreign key related names that might have been set on the through models.

class CustomSafeDeleteManager(SafeDeleteManager):
    def get_queryset(self):
        through = getattr(self, "through", None)
        if through and issubclass(through, SafeDeleteModel):
            for field in through._meta.get_fields():
                # Check if the field is a `ForeignKey` to the current model
                if (
                    isinstance(field, models.ForeignKey)
                    and field.related_model == self.model
                ):
                    # Filter out objects based on deleted through objects using related name or model name
                    through_lookup = (
                        field.remote_field.related_name or through._meta.model_name
                    )
                    self.core_filters.update(
                        {f"{through_lookup}__deleted__isnull": True}
                    )
        return super().get_queryset()

I'd love to hear if anyone else has solved this in a different way, or how people are still using Django's m2m fields with safedelete.

Also, if anyone knows of any issues we may have introduced generally into the ORM by using this custom manager with all models please let me know!

@marianobianchi
Copy link

I'm also seeing the same behaviour. As far as I can tell this makes the safedelete package basically incompatible with m2m relationships with through models?

At the moment we have been able to get something that works by generalising @charles-hussey's solution so that it can handle being passed to all models, and with any custom foreign key related names that might have been set on the through models.

class CustomSafeDeleteManager(SafeDeleteManager):
    def get_queryset(self):
        through = getattr(self, "through", None)
        if through and issubclass(through, SafeDeleteModel):
            for field in through._meta.get_fields():
                # Check if the field is a `ForeignKey` to the current model
                if (
                    isinstance(field, models.ForeignKey)
                    and field.related_model == self.model
                ):
                    # Filter out objects based on deleted through objects using related name or model name
                    through_lookup = (
                        field.remote_field.related_name or through._meta.model_name
                    )
                    self.core_filters.update(
                        {f"{through_lookup}__deleted__isnull": True}
                    )
        return super().get_queryset()

I'd love to hear if anyone else has solved this in a different way, or how people are still using Django's m2m fields with safedelete.

Also, if anyone knows of any issues we may have introduced generally into the ORM by using this custom manager with all models please let me know!

I tried this solution and it works only if no prefetch was made. If a prefetch was used, the core_filters updated in that piece of code are not used and you get all instances, no matter if the through model was safe deleted or not. I debug the code and this is what I found:

imagen

The query was being returned in that 1084 line (from the prefetch cache) instead of the queryset with the filters applied.

We ended up using a custom prefetch (no the basic prefetch_related("name_of_m2m")) to solve the issue: query.prefetch_related(Prefetch("name_of_m2m", queryset=Model.objects.filter(throughmodel__deleted__isnull=False)))

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

No branches or pull requests

6 participants