Django 04 外键和多表查询

作者 柚爸

在建立一个最简单的对单表进行增删改查并且输出的应用之后,Django基础的架构已经知道了.现在进行一些更复杂的通过外键的多表查询以及多对多等技巧.通过建立一个图书管理系统来学习.

设计表和建立数据表

数据库的设计是非常重要的,这里先设计图书与出版社对应的关系,由于在版权周期内,一本书只对应一个出版社,一个出版社可以出版很多书,所以设计表格如下:

Publisher 出版社表
id name
主键id 出版社名称
Book 书表
id title publiser_id
主键id 书名 书的出版社名称-外键连到出版社表主键

设计出表格之后,新建一个APP叫做book,在其models.py中编写ORM代码如下:

from django.db import models

class Publisher(models.Model):
    id = models.AutoField(primary_key=True)
    name = models.CharField(max_length=64, null=False, unique=True)

class Book(models.Model):
    id = models.AutoField(primary_key=True)
    title = models.CharField(max_length=64, null=False, unique=True)
    # 创建外键
    publisher = models.ForeignKey(to="Publisher")

这里有两个地方要注意:

  1. 一是编写外键的to属性的值,在这个例子里,Book类建立在Publisher类之后,所以可以直接写成to=Publisher,但是不推荐这么做,最好写成类的名字的字符串.
  2. 二是publisher在表内会被改写成publisher_id字段名,涉及到外键,会自动在后边加_id,所以表的实际字段名称是publisher_id

建立模板和视图

之前的项目是建立了单表的增删改查,对于单独修改出版社和单独修改书籍来说都不成问题.连表做成一行其实也没有问题,只要注意取到的是按照书来取得id就可以顺利删除.这次的页面和选择要做的更加复杂.
对于publisher这张表,就和之前做的增删改查是一样的,不再赘述.现在要做的,是Book表的增删改查.这里由于每一本书和出版社有对应关系,我们假定出版社那张表在添加书之前是必须先被添加出版社才行,不能够同时添加出版社和书,只能够在给定的出版社内选择.(其实如果给定一个表单同时输入书名和出版社的话,后台需要做判断,首先判断出版社是否存在,再判断书是否存在,然后先添加出版社再添加书.可以在option的最后一个弄一个固定值表示新的出版社用于添加.现在还没有用上JS,以后用JS可以比较方便的使用.)

展示书籍的页面和函数:

def books(request):
    book_list = models.Book.objects.all()
    return render(request, 'books.html', {"book_list": book_list})
<div class="container">
    <h1>书籍列表</h1>
    <table class="table table-striped table-bordered">
        <thead>
        <tr>
            <th>序号</th>
            <th>书籍id</th>
            <th>书籍名称</th>
            <th>出版社</th>
            <th>操作</th>
        </tr>
        </thead>
        <tbody>
        {% for book in book_list %}
        <tr>
        <th>{{ forloop.counter }}</th>
        <th>{{ book.id }}</th>
        <th>{{ book.title }}</th>
        <th>{{ book.publisher.name }}</th>
        <th><a href="/edit_book/?id={{ book.id }}" class="btn btn-primary">修改</a>
            <a href="/del_book/?id={{ book.id }}" class="btn btn-danger">删除</a>
        </th>
        </tr>
        {% endfor %}
        </tbody>
    </table>
    <a href="/add_book/" class="btn btn-primary">增加书籍</a>
</div>

这里的关键点是外键查询的写法:要显示书籍对应的出版社,直接调用Book对象的publisher属性,可以得到通过外键关联的出版社表的行对象,再用name取出出版社名字即可.

增加书籍的函数和页面

和原来相比主要的变化就是需要增加一个下拉菜单供选择出版社.

def add_book(request):
    if request.method == "POST":
        book_name = request.POST.get("title", None)
        pub_id = request.POST.get("publisher", None)
        models.Book.objects.create(title=book_name, publisher_id=pub_id).save()
        return redirect("/books/")
    pub_list = models.Publisher.objects.all()
    return render(request, 'add_book.html', {"publisher_list": pub_list})

页面里增加一组SELECT-OPTION用于读入出版社的数据.每个option的value用出版社的id来赋值,返回的时候即可取得书籍的名称和对应出版社的id

<div class="container">
    <h3>请输入书籍名称</h3>
    <form action="/add_book/" method="post">
        <div class="form-group">
            <label for="bookname">书籍名称</label>
            <input type="text" id="bookname" class="for" name="title">
        </div>
        <div class="form-group">
            <label for="publisher_list">选择出版社</label>
            <select name="publisher" id="publisher_list">
            {% for publisher in publisher_list %}
                <option value="{{ publisher.id }}">{{ publisher.name }}</option>
            {% endfor %}
            </select>
        </div>
        <button class="btn btn-primary" type="submit">提交</button>
    </form>
</div>

修改书籍的函数和页面

这个页面非常类似于增加的页面,不同点是需要将书名默认填写在框中,另外判断一下书名不能为空,还需要采用一个隐藏的表单元素来存放书籍ID.从books页面里的a标签用GET请求附加书籍id数据的方式,然后返回一个表单页面供提交.这里还一个要注意的就是,应该让书对应的出版社默认是选中状态,以展示其原来的内容.这里要增加一些处理.

def edit_book(request):
    if request.method == "GET":
        book_id = request.GET.get("id", None)
        if book_id:
            edit_obj = models.Book.objects.get(id=book_id)
            pub_id = edit_obj.publisher_id
            pub_list = models.Publisher.objects.all()
            return render(request, 'edit_book.html',
                          {"publisher_list": pub_list, "default": edit_obj.title, "book_id": book_id,"pub_id":pub_id})
        else:
            return redirect("/books/")
    else:
        book_name = request.POST.get("title", None)
        book_id = request.POST.get("book_id", None)
        pub_id = request.POST.get("publisher", None)
        edit_obj = models.Book.objects.get(id=book_id)
        edit_obj.title = book_name
        edit_obj.publisher_id = pub_id
        edit_obj.save()
        return redirect("/books/")

页面中需要添加一个隐藏的input元素用来接受当前的书籍id,以供返回表单时候获取需要修改的书籍id.

<div class="container">
    <h3>请修改书籍名称</h3>
    <form action="/edit_book/" method="post">
        <div class="form-group">
            <label for="bookname">书籍名称</label>
            <input type="text" id="bookname" class="for" name="title" value="{{ default }}">
        </div>
        <div class="form-group">
            <label for="publisher_list">选择出版社</label>
            <select name="publisher" id="publisher_list">
            {% for publisher in publisher_list %}
                {% if publisher.id == pub_id %}
                    <option value="{{ publisher.id }}" selected>{{ publisher.name }}</option>
                {% else %}
                     <option value="{{ publisher.id }}">{{ publisher.name }}</option>
                {% endif %}
            {% endfor %}
            </select>
        </div>
        <input type="hidden" value = "{{ book_id }}" name="book_id">
        <button class="btn btn-primary" type="submit">提交</button>
    </form>
</div>

这里用到了模板语言的判断语句.由于要把原本的书名和出版社名展示给用户,所以除了传送通过书id得到的书名以外,还必须传送书表内的publisher_id给页面,然后用模板语言的if -else -endif语句来做判断.在列出所有出版社的时候,如果出版社的id与选中的书的外键id相同,则该选项加上selected,否则就不加,这样就得到了将原来的书名和出版社作为默认修改内容的方法.

删除书籍的函数和页面

删除书籍如果直接用GET请求返回id是可以的,但是这样可以通过URL直接操作,存在一定风险,做法是返回一个固定内容的表单,等待用户再次确认.只需要返回书籍ID即可.

def del_book(request):
    if request.method == "GET":
        book_id = request.GET.get('id', None)
        book_obj = models.Book.objects.get(id=book_id)
        return render(request, 'del_book.html',
                      {"default": book_obj.title, "pub_name": book_obj.publisher.name, "book_id": book_id})

    else:
        book_id = request.POST.get('book_id', None)
        del_obj = models.Book.objects.get(id=book_id)
        del_obj.delete()
        return redirect("/books/")

页面上改成不可修改的输入框,用于提示用户,增加一个返回按钮.

<div class="container">
    <h3>请确认需要删除的书籍名称</h3>
    <form action="/del_book/" method="post">
        <div class="form-group">
            <label for="bookname">书籍名称</label>
            <input type="text" id="bookname" class="for" name="title" value="{{ default }}" disabled>
        </div>
        <div class="form-group">
            <label for="publisher_list">出版社名称</label>
            <input type="text" name="pub_name" id="publisher_list" value="{{ pub_name }}" disabled>
        </div>
        <input type="hidden" value = "{{ book_id }}" name="book_id">
        <button class="btn btn-danger" type="submit">确认</button>
        <a href="/books/" class="btn btn-primary">取消</a>
    </form>
</div>

增删改查系统主要是采用了外键一对多的方式操作数据库,以及Django的模板语言中的if条件判断.


增加作者以及多对多关系

现在我们给这个系统再增加一张作者表.由于一本书可以有多个作者,一个作者也可以写多个书,因此作者和书之间是一个多对多的关系.
在原来直接操作MySQL的时候,需要增加一张作者表,然后用一个两个外键的表存放书和作者的多对多关系.但是在Django的ORM里,需要先创建作者表,然后用一个特殊的方式构建多对多关系:

class Author(models.Model):
    id = models.AutoField(primary_key=True)
    name = models.CharField(max_length=32,null=False,unique=True)
    # 创建简单的多对多的需求
    book = models.ManyToManyField(to="Book")

这里的多对多指的是书和作者是多对多关系,由于我是作者表,所以我想要拿书,就把book变量的值设置为特殊的与book表的多对多关系,执行makemigrations和migrate之后,会发现数据库里多了两张表,一张是这个author表,另外一张是author_book表,就是通过多对多关系创建的新表,每一行分别外链到两张表.这恰好就是所需要的.然后给两张表随便添加一些数据,之后准备编写CRUD

展示作者以及所有的书

展示作者的基本逻辑比较简单,如果想要展示作者所有的书,放在同一个格子里,用一些特殊符号隔开,来看一下如何实现:

def author_list(request):
    all_author = models.Author.objects.all()
    return render(request, "author_list.html", {"author_list": all_author})

函数比较简单,返回了所有作者的清单,后边的部分主要体现在模板语言里

<div class="container">
<h1>作者列表</h1>
    <table class="table table-bordered table-striped">
        <thead>
        <tr>
            <th>#</th>
            <th>id</th>
            <th>姓名</th>
            <th>著作</th>
        </tr>
        </thead>
        <tbody>
        {% for author in author_list %}
        <tr>
        <td>{{ forloop.counter }}</td>
        <td>{{ author.id }}</td>
        <td>{{ author.name }}</td>
        <td>{% for book in author.book.all %}
            {% if forloop.last %}
                <span>{{ book.title }}</span>
            {% else %}
                <span>{{ book.title }} | </span>
            {% endif %}
        {% endfor %}</td>
        </tr>
        {% endfor %}
        </tbody>
    </table>
</div>

这里的关键在于第四列的数据填充设置.
首先是多对多的取值问题:这里通过author.book.all 取到了作者对应的所有书.这是模板语言,而在python里,实际上是author.book.all()来得到对应的所有数据.
之后,想用管道符分割各个书名而显示在同一行内,这里用了判断forloop.last来判断是否为队列末尾,如果是则插入不带管道符的内联元素,如果不是,则插入带管道符的内联元素.这样就实现了需求.
这里的关键就是models里如何定义多对多关系,以及在python和模板语言内如何使用多对多关系(具体到每一个元素来说其实还是一对多的关系)

添加作者

这里如果单独向作者表内添加作者,其实比较简单,目前做的是,添加作者的时候同时与书进行关联,则在添加作者的页面上,还必须列出所有的书供选择,好产生对应关系.这里的关键是获取select返回的值并且添加多对多关系.

def add_author(request):
    if request.method == "POST":
        author_name = request.POST.get("author_name", None)
        # 拿checkbox和多选selelct的时候要用getlist
        books = request.POST.getlist("books")
        new_author_obj = models.Author.objects.create(name=author_name)
        # 直接用book.set,传入id列表来设置对应关系.
        new_author_obj.book.set(books)
        return redirect("/author_list/")
    book_list = models.Book.objects.all()
    return render(request, "add_author.html", {"book_list": book_list})

处理函数的关键有两步,一是对于取得多个值的表单元素如checkbox和select多选,要用getlist拿到所有值构成的一个列表.二是通过book属性的set方法,一次性将列表设置进多对多的表格里,无需再编写函数去一行一行追加.HTML页面的代码主要是select的option里要带上书籍的id供选择:

<div class="container">
    <h1>添加作者</h1>
    <form action="/add_author/" method="post">
        <div class="form-group">
            <label for="add">输入作者姓名:</label>
            <input type="text" id="add" name="author_name">
        </div>

        <div class="form-group">
            <select name="books" id="books" multiple="multiple" size="5">
                {% for book in book_list %}
                    <option value="{{ book.id }}">{{ book.title }}</option>
                {% endfor %}
            </select>
        </div>
        <button type="submit" class="btn btn-primary">提交</button>
    </form>
</div>

删除作者

删除作者主要是说明.delete()方法会做两个事情,一是先删除多对多表格里的书与作者关系,二是从作者表内删除作者,也就是无需编写额外代码再去操作多对多的表格.比较简单,就直接用GET请求写了:

def delete_author(request):
    if request.method == "GET":
        delete_id = request.GET.get("id")
        models.Author.objects.get(id=delete_id).delete()
        return redirect("/author_list/")

HTML页面像原来一样,在最后一列增加一个删除按钮.这里就一起把编辑按钮也加上去了

<div class="container">
<h1>作者列表</h1>
    <table class="table table-bordered table-striped">
        <thead>
        <tr>
            <th>#</th>
            <th>id</th>
            <th>姓名</th>
            <th>著作</th>
            <th>操作</th>
        </tr>
        </thead>
        <tbody>
        {% for author in author_list %}
        <tr>
        <td>{{ forloop.counter }}</td>
        <td>{{ author.id }}</td>
        <td>{{ author.name }}</td>
        <td>{% for book in author.book.all %}
            {% if forloop.last %}
                <span>{{ book.title }}</span>
            {% else %}
                <span>{{ book.title }} | </span>
            {% endif %}
        {% endfor %}</td>
        <td>
            <a href="/delete_author/?id={{ author.id }}" class="btn btn-danger">删除</a>
            <a href="/edit_author/?id={{ author.id }}" class="btn btn-primary">编辑</a>
        </td>
        </tr>
        {% endfor %}
        </tbody>
    </table>
    <a href="/add_author/" class="btn btn-primary">增加作者</a>
</div>

编辑作者

编辑作者的思路与增加是类似的,都需要拿到原来的设置,然后显示为默认设置,再让用户修改,之后再提交表单.又需要埋一个隐藏的input标签用于存放当前作者的id.还需要通过一个列表拿到该作者所有的book对应的id,列出所有的书籍,然后判断,当书的id在这个作者所写的书的范围内,则默认选中该选项.
其他设置新名称等都和增加很类似.

def edit_author(request):
    if request.method == "GET":
        author_id = request.GET.get("id")
        author_obj = models.Author.objects.get(id=author_id)
        
        # 该作者所写的书的id列表,用列表推导式获得
        book_author = [x.id for x in author_obj.book.all()]
        
        book_list = models.Book.objects.all()
        return render(request, 'edit_author.html',
                      {"author_name": author_obj.name, "book_list": book_list, "book_author": book_author,
                       "author_id": author_id})

    else:
        author_id = request.POST.get('author_id')
        author_new_name = request.POST.get("author_name")
        books = request.POST.getlist("books")
        author_obj = models.Author.objects.get(id=author_id)
        author_obj.name = author_new_name
        author_obj.book.set(books)
        author_obj.save()
        return redirect("/author_list/")

HTML页面内,需要用到新的模板语言 in:

<div class="container">
    <h1>添加作者</h1>
    <form action="/edit_author/" method="post">
        <div class="form-group">
            <label for="add">输入作者姓名:</label>
            <input type="text" id="add" name="author_name" value="{{ author_name }}">
        </div>

        <div class="form-group">
            <select name="books" id="books" multiple="multiple" size="5">
                {% for book in book_list %}
                    {% if book.id in book_author %}
                    <option value="{{ book.id }}" selected="selected">{{ book.title }}</option>

                    {% else %}
                        <option value="{{ book.id }}">{{ book.title }}</option>
                    {% endif %}

                {% endfor %}
            </select>
        </div>
        <input type="hidden" name="author_id" value = "{{ author_id }}">
        <button type="submit" class="btn btn-primary">提交</button>

    </form>
</div>

HTML里用了 {% if book.id in book_author %} 来表示,当书属于该作者所写,则默认选中该项,否则不选中.
这个图书管理系统的逻辑是从最小约束开始,先添加出版社,再添加出版社所属的书,最后添加作者与书的关系.在设计数据系统的时候,也要从最基础的约束条件也就是各个基础单表数据出发,然后再添加一对多的数据,最后添加多对多的数据.

至此,已经编写了两个比较小的项目,用到了比较基础的方法,包括Django基础配置,单表,一对多,多对多增删改查,单个表单元素和多选元素的提交和页面内使用,以及循环,判断的模板语言.对Django有了比较初步的印象,之后是对Django的每个模块进行更加深入的学习.