HiveBrain v1.2.0
Get Started
← Back to all entries
patternpythondjangoMinor

Django query ManyToMany for tagged items

Submitted by: @import:stackexchange-codereview··
0
Viewed 0 times
manytomanydjangotaggedqueryitemsfor

Problem

I've got a item inventory that each inventory item is tagged with multiple tags. What I've got is an API that takes a query parameter with multiple comma separated query items and returns a list of all items that match all of the tags.

The following code is working, however this isn't really using the ManyToMany attributes in the Django models and I'm sure there is a better way to do this.

Schema ---

class InventoryTag(models.Model):
    class Meta:
        unique_together = ('key', 'value')

    key   = models.CharField(max_length=240, db_index=True)
    value = models.CharField(max_length=240, db_index=True)

class InventoryItem(models.Model):
    instance_id = models.CharField(max_length=255)

    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    tags = models.ManyToManyField(InventoryTag, through='InventoryItemTag', through_fields=('item', 'tag'))

    def as_json(self):
        return serializers.serialize('json', [self])[0]

class InventoryItemTag(models.Model):
    class Meta:
        unique_together = ('item', 'tag')

    item = models.ForeignKey(InventoryItem, db_index=True, related_name='invitem')
    tag  = models.ForeignKey(InventoryTag, db_index=True, related_name='invtag')

    created_at = models.DateTimeField(auto_now_add=True)


Get Function --

```
def get(self, request, *args, **kwargs):
query = request.GET.get('q', '').strip()
start = int(request.GET.get('start', 0))
count = int(request.GET.get('count', 50))

query_items = [q.strip() for q in query.split(',') if q.strip()]

items = InventoryItem.objects
if query_items:
for q in query_items:
tags = InventoryTag.objects
q = q.lstrip(':')
if ':' in q:
(k, v) = q.split(':', 1)
tags = tags.filter(Q(key = k) & Q(value__startswith = v))
else:
tags = tags.filter(value__startswith = q)

tags_pks

Solution

You are, more or less, manually performing in Python what can be achieved in SQL using Q objects. Basically, you can chain Q objects to create an SQL query on the InventoryItems with one WHERE clause for each part of your HTTP query.

Also note that, on queryset operations that will be converted to a WHERE clause (like get, filter...) as well as Q objects, the keywords arguments are ANDed together. So there is no need in doing tags.filter(Q(key = k) & Q(value__startswith = v)) since tags.filter(key=k, value__startswith=v) performs the same query and is more readable.

Building the tags research query should already include the lookup that span relationship and can be something like:

relevant_tags = Q()  # Fallback, if the query is empty will select everything
for tag_searched in query_items:
    tag_searched = tag_searched.lstrip(':')
    try:
        tag_name, tag_value = tag_searched.split(':', 1)
    except ValueError:
        # tag_searched did not contain any :
        query = Q(tags__value__startswith=tag_searched)
    else:
        query = Q(tags__key=tag_name, tags__value__startswith=tag_value)

    relevant_tags = relevant_tags & query


And from there, we can retrieve objects we want in a single query:

items = InventoryItems.objects.filter(relevant_tags)


A few things to note:

  • if iterable just before for element in iterable is pointless as the for loop will be a no-op anyway if the iterable is empty;



  • you can use EAFP rather than LBYL, especially if you expect one path to happen more frequently than the other. I made a wild guess that the user will use the name:value syntax of the query more often than the simple value one. It is also to show an alternate syntax.



-
you can clean your query better in the list-comprehension to avoid an extra lstrip in the computation. Or you can avoid the list-comp and perform the whole cleanup in the for loop:

for tag_searched in query.split(','):
    tag_searched = tag_searched.strip().lstrip(':')
    if not tag_searched:
        continue
    ...


-
you never use start or count, you should remove them.

Code Snippets

relevant_tags = Q()  # Fallback, if the query is empty will select everything
for tag_searched in query_items:
    tag_searched = tag_searched.lstrip(':')
    try:
        tag_name, tag_value = tag_searched.split(':', 1)
    except ValueError:
        # tag_searched did not contain any :
        query = Q(tags__value__startswith=tag_searched)
    else:
        query = Q(tags__key=tag_name, tags__value__startswith=tag_value)

    relevant_tags = relevant_tags & query
items = InventoryItems.objects.filter(relevant_tags)
for tag_searched in query.split(','):
    tag_searched = tag_searched.strip().lstrip(':')
    if not tag_searched:
        continue
    ...

Context

StackExchange Code Review Q#149015, answer score: 2

Revisions (0)

No revisions yet.