Search code examples
pythondjangodjango-querysetmodels

Efficient reverse of queryset


I'm trying to look at the most efficient way to traverse my models to get the data that I'm after. I have three "related" models. Item, ProtectionList and Player

Protection List

class ProtectionList(models.Model):
    player = models.ForeignKey(Player)
    main_hand = models.ForeignKey(Item, null=True, blank=True, related_name='main_hand')
    off_hand = models.ForeignKey(Item, null=True, blank=True, related_name='off_hand')
    head = models.ForeignKey(Item, null=True, blank=True, related_name='head')
    neck = models.ForeignKey(Item, null=True, blank=True, related_name='neck')
    shoulder = models.ForeignKey(Item, null=True, blank=True, related_name='shoulder')
    back = models.ForeignKey(Item, null=True, blank=True, related_name='back')
    chest = models.ForeignKey(Item, null=True, blank=True, related_name='chest')
    wrist = models.ForeignKey(Item, null=True, blank=True, related_name='wrist')
    hands = models.ForeignKey(Item, null=True, blank=True, related_name='hands')
    waist = models.ForeignKey(Item, null=True, blank=True, related_name='waist')
    legs = models.ForeignKey(Item, null=True, blank=True, related_name='legs')
    feet = models.ForeignKey(Item, null=True, blank=True, related_name='feet')
    ring1 = models.ForeignKey(Item, null=True, blank=True, related_name='ring1')
    ring2 = models.ForeignKey(Item, null=True, blank=True, related_name='ring2')
    trinket1 = models.ForeignKey(Item, null=True, blank=True, related_name='trinket1')
    trinket2 = models.ForeignKey(Item, null=True, blank=True, related_name='trinket2')
    locked = models.BooleanField(default=False)

    def __unicode__(self):
        return self.player.main_character.name

Player

class Player(models.Model):
    accountID = models.CharField(max_length=255, null=True, blank=True)
    battletag = models.CharField(max_length=255, null=True, blank=True)
    user = models.OneToOneField(User, null=True, blank=True)
    main_character = models.ForeignKey('Character', null=True, blank=True, related_name='main_character')
    signature = models.TextField(null=True, blank=True)

    def __unicode__(self):
         return self.battletag

Item

class Item(models.Model):
    '''Details individual data for each item on a loot table'''
    name = models.CharField(max_length=255, null=True)
    item_id = models.IntegerField(null=True)
    dropped_by = models.ForeignKey(Boss)
    warforgeable = models.BooleanField(default=True)
    bonus_string = models.CharField(max_length=255, blank=True, null=True)

    def __unicode__(self):
        return self.name

I need to map my way back to the Player to make a list of Player objects dependant on who has the Item in any field on their ProtectedList.

I know Item is related to ProtectionList and ProtectionList to Player however I want to find an efficient way of checking in all the fields where Item is a ForeignKey within the ProtectionList object as the Item being filtered could potentially be in any field (minus the player and locked fields).

I have considered the use of Q to OR each of the fields, but I'm not sure how optimal this would be on larger querysets.

I am also looking at through intermediate tables and changing the model to a many-to-many.


Solution

  • I may be wrong, but i feel like your model structure is not normalized. You have a M2M relation between Player and Item via Slots. Consider the following model structure:

    from django.db import models
    from django.contrib.auth.models import User
    
    class Slot(models.Model):
        #e.g. main_hand, off_hand, head, ... , feet
        name = models.CharField(max_length=255)
    
        def __unicode__(self):
            return self.name
    
    
    class Item(models.Model):
        '''Details individual data for each item on a loot table'''
        name = models.CharField(max_length=255, null=True)
        item_id = models.IntegerField(null=True)
        """removed for the example"""
        #dropped_by = models.ForeignKey(Boss)
        warforgeable = models.BooleanField(default=True)
        bonus_string = models.CharField(max_length=255, blank=True, null=False, default='')
    
        def __unicode__(self):
            return self.name
    
    
    class PlayerManager(models.Manager):
        def create_player_with_empty_slots(self, **kwargs):
            player = Player.objects.create(**kwargs)
            PlayerSlot.objects.bulk_create([
                PlayerSlot(player=player, slot=slot) for slot in Slot.objects.all()
            ])
            return player
    
    
    class Player(models.Model):
        user = models.OneToOneField(User, null=True, blank=True)
        """removed for the example"""
        #accountID = models.CharField(max_length=255, null=True, blank=True)
        #battletag = models.CharField(max_length=255, null=True, blank=True)
        #main_character = models.ForeignKey('Character', null=True, blank=True, related_name='main_character')
        #signature = models.TextField(null=True, blank=True)
    
        slots = models.ManyToManyField(Slot, through='PlayerSlot')
        items = models.ManyToManyField(Item, through='PlayerSlot')
    
        objects = PlayerManager()
    
        def __unicode__(self):
            """changed for the example"""
            return self.user.username
    
        def get_slot(self, slot_or_slotname):
            if isinstance(slot_or_slotname, Slot):
                return self.playerslot_set.get(slot=slot_or_slotname)
            else:
                return self.playerslot_set.get(slot__name=slot_or_slotname)
    
        def set_item(self, item, at_slot):
            slot = self.get_slot(at_slot)
            slot.item = item
            slot.save()
    
    
    class PlayerSlot(models.Model):
        player = models.ForeignKey(Player)
        slot = models.ForeignKey(Slot)
        #item is optional, to allow empty slots
        item = models.ForeignKey(Item, null=True, blank=True)
        locked = models.BooleanField(default=False)
    
        class Meta:
            unique_together = ('player', 'slot')
    
        def __unicode__(self):
            return "{player} carry {item} on {slot}".format(player=self.player, item=self.item or 'nothing', slot=self.slot)
    

    Playing with the API's

    >>> from django.contrib.auth.models import User
    >>> from game.models import Slot, Item, Player, PlayerSlot
    

    Lets create some initial data:

    >>> Slot.objects.bulk_create([
    ...     Slot(name='main_hand'),
    ...     Slot(name='off_hand'),
    ...     Slot(name='head'),
    ...     Slot(name='neck'),
    ...     Slot(name='shoulder'),
    ...     Slot(name='back'),
    ...     Slot(name='chest'),
    ...     Slot(name='wrist'),
    ...     Slot(name='hands'),
    ...     Slot(name='waist'),
    ...     Slot(name='legs'),
    ...     Slot(name='feet'),
    ...     Slot(name='ring1'),
    ...     Slot(name='ring2'),
    ...     Slot(name='trinket1'),
    ...     Slot(name='trinket2'),
    ... ])
    [<Slot: main_hand>, <Slot: off_hand>, <Slot: head>, <Slot: neck>, <Slot: shoulder>, <Slot: back>, <Slot: chest>, <Slot: wrist>, <Slot: hands>, <Slot: waist>, <Slot: legs>, <Slot: feet>, <Slot: ring1>, <Slot: ring2>, <Slot: trinket1>, <Slot: trinket2>]
    
    
    >>> Item.objects.bulk_create([
    ...     Item(name='Short Sword'),
    ...     Item(name='Sabre'),
    ...     Item(name='Broadsword'),
    ...     Item(name='Apprentice Broadsword'),
    ...     Item(name='Monster Hunter'),
    ... ])
    [<Item: Short Sword>, <Item: Sabre>, <Item: Broadsword>, <Item: Apprentice Broadsword>, <Item: Monster Hunter>]
    
    
    >>> user1 = User.objects.create_user('john', 'lennon@thebeatles.com', 'johnpassword')
    >>> user2 = User.objects.create_user('paul', 'mccartney@thebeatles.com', 'mccartnypassword')
    
    >>> player1 = Player.objects.create_player_with_empty_slots(user=user1)
    >>> player2 = Player.objects.create_player_with_empty_slots(user=user2)
    

    Get all player slots:

    >>> player1.slots.all()
    [<Slot: main_hand>, <Slot: off_hand>, <Slot: head>, <Slot: neck>, <Slot: shoulder>, <Slot: back>, <Slot: chest>, <Slot: wrist>, <Slot: hands>, <Slot: waist>, <Slot: legs>, <Slot: feet>, <Slot: ring1>, <Slot: ring2>, <Slot: trinket1>, <Slot: trinket2>]
    

    Get all player items:

    >>> player1.items.all()
    []
    

    Adding new item?

    >>> broadsword = Item.objects.get(name='Broadsword')
    >>> player1.set_item(broadsword, at_slot='main_hand')
    >>> player1.items.all()
    [<Item: Broadsword>]
    

    Check what is inside some slot?

    >>> player1.get_slot('main_hand')
    <PlayerSlot: john carry Broadsword on main_hand>
    

    Find players having a item:

    >>> Player.objects.filter(items=broadsword)
    [<Player: john>]
    

    Some more example's by Two-Bit Alchemist:

    Here are some example queries for this model structure:

    1. You have a Player object player and need to know what is in his/her left hand:

      player.items.filter(slot__name='off_hand')[0].item    # Could be None
      
    2. You have an Item object item and wish to know what slot player has it equipped in:

      PlayerSlot.objects.filter(player=player).filter(item=item)[0].slot.name
      
    3. You have an Item object item and wish to know what players have this item at all:

      PlayerSlot.objects.filter(item=item).all()
      

    or

        Player.objects.filter(slots__item=item).all()
    

    depending on which object you'd rather work with. I haven't tested any of the queries above but this should get you started. Also note that chaining this way (queries 1 & 2) may throw IndexError if the relationships don't line up the way you think (e.g., player does not actually have item), so be prepared to possibly catch that.

    Migrating old data

    I believe something like this can do the job:

    plist_slots = [
        'main_hand', 'off_hand', 'head', 'neck', 'shoulder', 
        'back', 'chest', 'wrist', 'hands', 'waist', 'legs',
        'feet', 'ring1', 'ring2', 'trinket1', 'trinket2'
    ]
    
    Slot.objects.bulk_create([
        Slot(name=slot) for slot in plist_slots
    ])
    
    plist = ProtectionList.objects.all().select_related(
        *list(['player'] + plist_slots)
    )
    
    for p in plist:
        PlayerSlot.objects.bulk_create([
            PlayerSlot(
                player=p.player,
                item=getattr(p, slot),
                slot=Slot.objects.get(name=slot)
            ) for slot in plist_slots
        ])