3

In case we had the model:

class Publication(models.Model):
    title = models.CharField(max_length=30)

class Article(models.Model):
    publications = models.ManyToManyField(Publication)

According to: https://docs.djangoproject.com/en/4.0/topics/db/examples/many_to_many/, to create an object we must have both objects saved before we can create the relation:

p1 = Publication(title='The Python Journal')
p1.save()
a1 = Article(headline='Django lets you build web apps easily')
a1.save()
a1.publications.add(p1)

Now, if we called delete in either of those objects the object would be removed from the DB along with the relation between both objects. Up until this point I understand.

But is there any way of doing that, if an Article is removed, then, all the Publications that are not related to any Article will be deleted from the DB too? Or the only way to achieve that is to query first all the Articles and then iterate through them like:

to_delete = []
qset = a1.publications.all()
for publication in qset:
    if publication.article_set.count() == 1:
        to_delete(publication.id)
a1.delete()
Publications.filter(id__in=to_delete).delete()

But this has lots of problems, specially a concurrency one, since it might be that a publication gets used by another article between the call to .count() and publication.delete().

Is there any way of doing this automatically, like doing a "conditional" on_delete=models.CASCADE when creating the model or something?

Thanks!

lpares12
  • 3,504
  • 4
  • 24
  • 45
  • if you want to remove the related objects from DB, then you can simply call `delete`: `publication.article_set.delete()`, if you want to unlink the related objects, then call `clear`: `publication.article_set.clear()` – Ersain Jan 27 '22 at 06:50
  • I would like to call the `delete()` function on all Articles that are not referenced by other `Publication` avoiding any concurrency issues – lpares12 Jan 27 '22 at 06:58
  • 1
    Then you could probably do it with `annotate` and `Count`: `Publication.objects.annotate(article_count=Count('article_set')).filter(article_count=1).delete()` – Ersain Jan 27 '22 at 07:37
  • @Ersain that would be `article_count=0` after deleting the article (generalized tidy-up), or you need to filter only `publications.filter(article=a1).annotate(...` before `a1.delete()` – nigel222 Jan 27 '22 at 09:51
  • 2
    @nigel222 or `a1.publications.annotate(article_count=Count('article_set')).filter(article_count=1).delete()` – Ersain Jan 27 '22 at 09:56
  • @Ersain I don't really understand what the `Count` class does. Would it be possible to get an answer with a short explanation of both `Count` and `annotate`? As far as I understand `annotate` just executes the following function calls to all elements of the `QuerySet`, is that right? So in this case it will execute the filter+delete to all these elements. – lpares12 Jan 27 '22 at 10:31
  • @nigel222 any idea why the `article_set` is not being found in the Article? Only `article, id, title` exist – lpares12 Jan 29 '22 at 15:31
  • Possibly I'm wrong about the implicit name. It might be article or articles. Consult the docs, or set it explicitly on the other model using related_name=whatever on the ManyToMany field definition. – nigel222 Jan 31 '22 at 09:36

2 Answers2

1

I tried with @Ersain answer:

a1.publications.annotate(article_count=Count('article_set')).filter(article_count=1).delete()

Couldn't make it work. First of all, I couldn't find the article_set variable in the relationship.

django.core.exceptions.FieldError: Cannot resolve keyword 'article_set' into field. Choices are: article, id, title

And then, running the count filter on the QuerySet after filtering by article returned ALL the tags from the article, instead of just the ones with article_count=1. So finally this is the code that I managed to make it work with:

Publication.objects.annotate(article_count=Count('article')).filter(article_count=1).filter(article=a1).delete()

Definetly I'm not an expert, not sure if this is the best approach nor if it is really time expensive, so I'm open to suggestions. But as of now it's the only solution I found to perform this operation atomically.

lpares12
  • 3,504
  • 4
  • 24
  • 45
0

You can remove the related objects using this query:

a1.publications.annotate(article_count=Count('article_set')).filter(article_count=1).delete()

annotate creates a temporary field for the queryset (alias field) which aggregates a number of related Article objects for each instance in the queryset of Publication objects, using Count function. Count is a built-in aggregation function in any SQL, which returns the number of rows from a query (a number of related instances in this case). Then, we filter out those results where article_count equals 1 and remove them.

Ersain
  • 1,466
  • 1
  • 9
  • 20
  • 1
    I tried to follow your steps and found a few issues. First of all, in my model I have a `Card` which has a `m2m` relation with `Tag`, which only contains a `CharField name`. The call `card.tags.annotate(card_count=Count('card_set')).filter(card_count=1).delete()` produces this error: `django.core.exceptions.FieldError: Cannot resolve keyword 'card_set' into field. Choices are: card, id, name`. If I set it as `card` instead of `card_set`, no exception is thrown, but all the `tags` used by this card get removed, despite other cards using those tags too, so the filter is not executed. – lpares12 Jan 27 '22 at 12:49
  • This is how I put in `Card` model: `tags = models.ManyToManyField(Tag, blank=True)` so no `related_name` field. Not sure what `card_set` is not in there. And then again, not sure why the filter is not being executed. – lpares12 Jan 28 '22 at 07:25
  • 1
    Tried with the Django shell. Created two cards with tags `[aa, bb]` and `[aa, cc]`: `Tag.objects.annotate(card_count=Count('card'))` results in `QuerySet [, , ]`. If I filter: `Tag.objects.annotate(card_count=Count('card')).filter(card_count=1)` it shows correctly `QuerySet [, ]`. But if I do it with the card: `card1.tags.annotate(card_count=Count('card'))` -> `, ]>` and adding the `.filter(card_count=1)` returns `QuerySet [, ]`. So clearly the filter is not applied when looking from the card objects. – lpares12 Jan 28 '22 at 07:45