Django的ManyToManyField在数据库中如何表示?

3 投票
2 回答
5203 浏览
提问于 2025-04-18 18:06

更新: 我刚发现,ManyToManyField导致管理界面崩溃,当选择特定专辑时。于是我把它注释掉了,所有相关的引用也注释掉了,重新运行了 makemigrationsmigrate,现在管理界面又能用了……这让我离实现这个“收藏”列的功能更远了 :( 请查看这个后续问题: 为什么Django的ManyToManyField会导致管理界面崩溃?为什么没有创建中间表?


背景:我的目标是让这个网页中的“收藏?”列反映当前登录用户的收藏专辑,每个专辑的状态要么是“否”,要么是“是”,并且是一个可以点击的链接,用来切换选择。(如果没有登录,所有专辑都会显示为灰色的“n/a”。)

在这里输入图片描述

因此,对于每个专辑,每个用户最多只能有 零或一个 “已收藏” 的记录。如果记录存在,说明他们收藏了这个专辑;如果不存在,说明他们没有收藏。

这是我的 Album 模型,其中有一个 favorited_by_users 的多对多列(完整的 models.py 在下面):

class Album(models.Model):
    OFFICIALITY = (
        ('J', 'Major studio release'),
        ('I', 'Non-major official release'),
        ('U', 'Unofficial'),
    )
    title = models.CharField(max_length=70)
    description = models.TextField(max_length=500, default="", null=True, blank=True)
    pub_date = models.DateField('release date')
    officiality = models.CharField(max_length=1, choices=OFFICIALITY)
    is_concert = models.BooleanField(default=False)
    main_info_url = models.URLField(blank=False)
    thumbnail = models.FileField(upload_to=get_upload_file_name, blank=True, null=True)

    #virtual field to skip over the through table.
    songs = models.ManyToManyField("Song", through="AlbumSong")

    favorited_by_users = models.ManyToManyField(User)

    def __str__(self):
        return  self.title

    class Meta:
        #Default ordering is by release date, ascending.
        ordering = ['pub_date']

我最开始有一个 FavoriteAlbum 模型,但因为它除了外键没有其他额外信息,所以建议我用上面的多对多列来替代它。

class FavoriteSongs(models.Model):
    user = models.ForeignKey(User)
    song = models.ForeignKey(Song)

    class Meta:
        unique_together = ('user', 'song',)

    def __str__(self):
        return  "user=" + str(self.user) + ", song=" + str(self.song)

我需要做的是在专辑和用户之间进行一个“左连接”,选择所有专辑,并将当前登录用户的收藏专辑连接上去(如果他们没有收藏,则为 None)。我不知道该怎么做。

我还听说过 extra() 函数可以用来进行这个连接。当前在视图的 get_queryset() 中工作的查询是

return  super(AlbumList, self).get_queryset().order_by("pub_date")

(完整的 views.py 在下面。)我现在的猜测是:

return super(AlbumList, self).get_queryset().order_by("pub_date").extra(select={"is_favorite": "favorited_by_users__id = " + str(request.user.id) })

但是,虽然这不会崩溃,但模板中每个 {{ is_favorite }} 的值却是 空的(空字符串)。这很合理,因为数据库中还没有数据,但接下来该怎么办呢?我不知道这是否是正确的Django查询。

我想在数据库中添加一条记录来测试这个,用手动的SQL语句在postgres中(还不通过Django命令),但我该怎么做,在哪里做呢?

我已经成功运行了 makemigrationsmigrate,并且有了这个新的多对多列(而没有 FavoriteSongs 模型),但我在数据库中看不到任何代表“是否收藏”值的内容。billyjoel_album 中没有额外的列,也没有类似于 billyjoel_favoritealbum 的中间表。那么,这些数据是如何存储在数据库中的呢?

(关于这个额外的“收藏”列的其他建议也非常欢迎!)

谢谢。


models.py

from  django.db import models
from  django.contrib.auth.models import User
from  time import time

def get_upload_file_name(instance, filename):
    return  "uploaded_files/%s_%s" % (str(time()).replace(".", "_"), filename)

class Album(models.Model):
    OFFICIALITY = (
        ('J', 'Major studio release'),
        ('I', 'Non-major official release'),
        ('U', 'Unofficial'),
    )
    title = models.CharField(max_length=70)
    description = models.TextField(max_length=500, default="", null=True, blank=True)
    pub_date = models.DateField('release date')
    officiality = models.CharField(max_length=1, choices=OFFICIALITY)
    is_concert = models.BooleanField(default=False)
    main_info_url = models.URLField(blank=False)
    thumbnail = models.FileField(upload_to=get_upload_file_name, blank=True, null=True)

    #virtual field to skip over the through table.
    songs = models.ManyToManyField("Song", through="AlbumSong")

    favorited_by_users = models.ManyToManyField(User)

    def __str__(self):
        return  self.title

    class Meta:
        #Default ordering is by release date, ascending.
        ordering = ['pub_date']


class Song(models.Model):
    name = models.CharField(max_length=100)
    description = models.TextField(max_length=500, default="", null=True, blank=True)
    length_seconds = models.IntegerField()
    lyrics_url = models.URLField(default="", blank=True, null=True)
    albums = models.ManyToManyField("Album", through="AlbumSong")

    favorited_by_users = models.ManyToManyField(User)

    def get_length_desc_from_seconds(self):
        if(self.length_seconds == -1):
            return  "-1"
        m, s = divmod(self.length_seconds, 60)
        h, m = divmod(m, 60)
        if(h):
            return  "%d:%02d:%02d" % (h, m, s)
        else:
            return  "%d:%02d" % (m, s)

    def __str__(self):
        return  self.name

class AlbumSong(models.Model):
    song = models.ForeignKey(Song)
    album = models.ForeignKey(Album)
    sequence_num = models.IntegerField()

    class Meta:
        unique_together = ('album', 'sequence_num',)
        unique_together = ('album', 'song',)

    def __str__(self):
        return  str(self.album) + ": " + str(self.sequence_num) + ": " + str(self.song)

views.py

from  .models import Album, Song, AlbumSong
from  datetime import datetime, timedelta
from  django.core.context_processors import csrf
from  django.shortcuts import render, render_to_response
from  django.views.generic import DetailView, ListView
from  enum import Enum

def get_str_with_appended(string, between_if_str_non_empty, new_value):
    if(len(string) == 0):
        return  new_value
    else:
        return  string + between_if_str_non_empty + new_value

class PrependQuestionMark(Enum):
    YES, NO = range(2)

def get_url_param_string_from_params(prepend_question_mark=PrependQuestionMark.YES, **kwargs_all_params):
    param_list = ""
    for  key in iter(kwargs_all_params):
        value = kwargs_all_params[key]
        if(value is not None):
            param_list = get_str_with_appended(param_list, '&', str(key) + "=" + str(value))

    if(len(param_list) == 0):
        return  param_list;

    if(prepend_question_mark == PrependQuestionMark.YES):
        return  "?" + param_list
    else:
        return  param_list

class AlbumList(ListView):
    model = Album
    context_object_name = "albums"

    #Derived from irc/#dango/tbaxter...START
    def dispatch(self, request, *args, **kwargs):
                                             #default to asc
        self.sort_order = request.GET.get("sort_order", None)
        self.sort_item = request.GET.get("sort_item", None)
        self.csrf_token = csrf(request)["csrf_token"]
        self.logged_in_user = request.user

        #self.csrf_token = request.GET.get("csrf_token", None)
        return super(AlbumList, self).dispatch(request, *args, **kwargs)

    def get_queryset(self):
        #Item zero in both is the default
        #should be static global
        asc_desc_list = ["asc", "dsc"]
        sort_by_types = ["pub_date", "title"]

        if(self.sort_order is None  and  self.sort_item is None):
            #Use default ordering
            return  super(AlbumList, self).get_queryset()

        #Custom ordering requested

        sort_order = self.sort_order
        sort_item = self.sort_item

        if(sort_order is None  or
                sort_order not in asc_desc_list):
            sort_order = asc_desc_list[0]
        if(sort_item is None  or
                sort_item not in sort_by_types):
            sort_item = sort_by_types[0]

        order_minus = ""  if  sort_order == "asc"  else "-"

        return  super(AlbumList, self).get_queryset().order_by(order_minus + sort_item).extra(select={"is_favorite": "favorited_by_users__id = " + str(self.logged_in_user.id) })

    def get_context_data(self, **kwargs):
        context = super(AlbumList, self).get_context_data(**kwargs)

        context["sort_order"] = self.sort_order
        context["sort_item"] = self.sort_item

        context["url_params"] = get_url_param_string_from_params(
            sort_item=self.sort_item,
            sort_order=self.sort_order,
            csrf_token=self.csrf_token)

        return  context


class AlbumDetail(DetailView):
    model = Album
    context_object_name = "album"

    def dispatch(self, request, *args, **kwargs):
                                             #default to asc
        self.sort_order = request.GET.get("sort_order", None)
        self.sort_item = request.GET.get("sort_item", None)
        self.csrf_token = csrf(request)["csrf_token"]

        return super(AlbumDetail, self).dispatch(request, *args, **kwargs)

    def get_context_data(self, **kwargs):
        #Call the base implementation first to get a context
        context = super(AlbumDetail, self).get_context_data(**kwargs)

        #Add in the required extra info: album_songs, ordered by
        #sequence_num

        #select_related is to query the database for all songs at once, here
        #in the view, to prevent the template from pin-pricking the database
        #in each for loop iteration. For large datasets, this is critical.
        context['album_songs'] = kwargs["object"].albumsong_set.order_by('sequence_num').select_related("song")

        context["url_params"] = get_url_param_string_from_params(
            sort_item=self.sort_item,
            sort_order=self.sort_order,
            csrf_token=self.csrf_token)

        return  context

album_list.html

 {% extends "base.html" %}
 {% load bj_filters %}
 {% block title %}Billy Joel Album Browser{% endblock %}

 {% block sidebar %}
 <UL>
  <LI><a href="{% url 'album_list' %}{{ url_params }}">All albums</A></LI>
  <LI><a href="/admin/">Admin</A></LI>
 </UL>
 {% endblock %}

 {% block content %}

 <TABLE ALIGN="center" WIDTH="100%" BORDER="1" CELLSPACING="0" CELLPADDING="4" BGCOLOR="#EEEEEE"><TR ALIGN="center" VALIGN="middle">
 {% if  user.is_authenticated %}
  <TD>My profile (<a href="{% url 'accounts_logout' %}">Logout</A>)</TD>
 {% else %}
  <TD><a href="{% url 'accounts_login' %}">Login</A> to view your favorites</TD>
 {% endif %}
 </TR></TABLE>

 <H1>Billy Joel Album Browser</H1>

 <!--
 <P>url_params={{ url_params }}</P>
  -->

 {% if  albums.count > 0 %}
  <P>Officiality: <IMG SRC="/static/images/major.jpg" height="20"/>=Major studio release, <IMG SRC="/static/images/minor.jpg" height="20"/>=Official release, <IMG SRC="/static/images/unofficial.jpg" height="20"/>=Unofficial</P>
  <TABLE ALIGN="center" WIDTH="100%" BORDER="1" CELLSPACING="0" CELLPADDING="4" BGCOLOR="#EEEEEE"><TR ALIGN="center" VALIGN="middle">
     <TD><B><U><a href="{% url 'album_list' %}?sort_item=title&sort_order=
        {% if  sort_item == 'pub_date' %}asc{% else %}
           {{ sort_order|multival_to_str:'asc,dsc->dsc,asc,dsc' }}
        {% endif %}
     &csrf_token={{ csrf_token }}">Title</A></U></B><BR><I><FONT SIZE="-1">(click a title to view its song list)</FONT></I></TD>
     <TD><B><U><a href="{% url 'album_list' %}?sort_item=pub_date&sort_order=
        {% if  sort_item == 'title' %}asc{% else %}
           {{ sort_order|multival_to_str:'asc,dsc->dsc,asc,dsc' }}
        {% endif %}
     &csrf_token={{ csrf_token }}">Released</A></U></B></TD>
     <TD>Officiality</TD>
     <TD>Concert</TD>
     <TD>Wiki</TD>
     <TD>Favorite?</TD>
  {% for  album in albums %} <!-- No colon after "albums" -->
  </TR><TR>
     <TD VALIGN="top">
        {% if  album.thumbnail %}
           <img src="/static/{{ album.thumbnail }}" width="25"/>
        {% else %}
           <img src="/static/images/white_block.jpg" width="25"/>
        {% endif %}
        &nbsp; <a href="/albums/get/{{ album.id }}{{ url_params }}">{{ album.title }}</a>
        {% if  album.description %}
           <BR/><FONT SIZE="-1"><I>{{ album.description|truncatewords:10 }}</I></FONT>
        {% endif %}
     <TD>{{ album.pub_date|date:"m/y" }}</TD>
     <TD><IMG SRC="/static/images/{{ album.officiality|multival_to_str:"J,I,U->major,minor,unofficial,broken_image"}}.jpg" height="20"/></TD>
     <TD>{{ album.is_concert|yesno:"Yes,No" }}</TD>
     <TD><A HREF="{{ album.main_info_url }}">Wiki</A></TD>
     <TD><I>n/a {{ is_favorite }}</I></TD>
  {% endfor %}
  </TR></TABLE>
 {% else %}
  <P><I>There are no albums in the database.</I></P>
 {% endif %}

 {% endblock %}

2 个回答

8

M2M关系是新创建的表格。它们有一个独特的名字,并且包含两个外键。为了确保直接模型和相关模型的组合是唯一的,会创建一个复合键。

当你执行以下操作时:

class Topping(models.Model):
    name = ...

class Pizza(models.Model):
    name = ...
    toppings = models.ManyToManyField(Topping, related_name="pizzas")
    #not including a related_name will generate a "pizza_set" related name.

会出现一个新表,描述这种关系,里面有一个内部名称。这个表有一个pizza_id和一个topping_id的外键,并且包含这两个字段的复合唯一键。你不能,也不应该去预测这个表的名字。

另一方面,如果你想访问这个关系,并可能声明更多的字段,你可以:

class Topping(models.Model):
    name = ...

class Pizza(models.Model):
    name = ...
    toppings = models.ManyToManyField(Topping, related_name="pizzas", through="PizzaAndTopping")
    #not including a related_name will generate a "pizza_set" related name.

class PizzaAndTopping(models.Model):
    more_data = models.TextField()
    pizza = models.ForeignKey(Pizza, null=False)
    topping = models.ForeignKey(Topping, null=False)

    class Meta:
        unique_together = (('pizza','topping'),)

注意我添加了一个through参数。现在你可以控制这个中间表但你不能从关系中添加或删除模型。这意味着,使用这种方法你不能:

Pizza.objects.get(pk=1).toppings.append(Topping.objects.get(pk=2))

你也不能在Topping这一边进行这些操作。

如果你想添加或删除pizza和topping之间的关系,你必须直接在PizzaAndTopping关系中进行。

如果你想知道当前用户是否标记了任何歌曲为他们的最爱,你应该预取这个关系。在Django 1.7中,你可以使用自定义过滤器来预取M2M相关字段:你可以获取所有专辑,并且只用一个查询来获取当前用户,使用Prefetch对象。关于预取的更多信息,可以查看官方文档这里

另一种解决方案包括:

  • 获取当前用户的最爱专辑列表:ulist = user.album_set.all()
  • 获取当前页面的专辑:_list = Album.objects.all()[0:20]
  • 获取用户专辑的值:ulist = ulist.values_list('id', flat=True)

    [1, 2, 4, 5, 10, ...] #you'll get a list of ids
    
  • 在遍历页面中的每个专辑时,你测试currentAlbum.id in ulist并打印不同的消息(要么是,是,要么不是)。

1

多对多字段在数据库中的表示方式和你原来的 FavouriteSongs 模型是完全一样的——它是一个链接表,里面有指向 Song 和 User 的外键。去掉 FavouriteSongs 的唯一好处就是你现在使用的是一个自动定义的中间表,而不是手动创建的。

我不太明白你给的查询示例,因为你没有说明你到底在调用哪个模型,也没有解释 self.logged_in_user 是什么。不过,你不能这样使用 extra:你试图在这里放入 Django 查询语法,里面有双下划线的名字来遍历关系,但 extra 是直接传给 SQL 的,而 SQL 并不知道这些语法。

我不会尝试在一个查询中完成这个操作。相反,我会分成两个查询,一个用来获取所有专辑,另一个用来获取用户的收藏。get_queryset 只需返回完整的专辑列表,然后你可以使用 get_context_data 来获取一个额外的对象集合,表示收藏的 ID:

favorites = self.logged_in_user.album_set.all().values_list('id', flat=True)
context['favorites'] = set(favorites)

这里的 values_list 只是获取专辑的 ID,因为这就是我们需要的,然后我们把它们放入一个集合中,以加快查找速度。

现在,在模板中,你可以直接这样做:

{% for album in albums %}
    ...
    <td>{% if album.id in favorites %}Yes{% else %}No{% endif %}</td>
{% endfor %}

撰写回答