多对多 + 非默认主键 + 中间表某列数据对应多表--稍稍复杂应用

前言

  • 如果你是刚开始学习Laravel,可能会感觉比较兴奋,同时在实操的过程中,又会很苦闷。特别是laravel提供的类有哪些可以用的方法,它们具体怎么用,每个参数该如何给对应的值,都是头疼的事情。
  • 也许你会说我可以查询官方API可以了解,但是又会发现文档写得实在是太简洁了,又必须进入到API对应的源代码中去拜读才能初步领会(时间开销较大)。
  • 其实,很多时候我们可以通过它给定的一些内部机制和方法,直接通过实例操作就可以摸索出运作原理。比如今天我们准备展开讲解的Eloquent 模型的多对多(非主键)关联中我们会在tinker中对model进行操作,只要不是执行的最终获取数据操作,我们都可以采用toSql方法查看我们的关联查询是否产出正确的sql语句。
  • 正式开始之前,我们先来关注这么几个问题:
    • 如果表的主键不是默认自增id怎么办?
    • 中间表中有一个字段同时又对应其他N个表怎么办?
    • 添加记录时如果pivot中间表有其他字段需要填入数据怎么办?
    • 假设我们有Teacher表与cities的关系为N:M,Teacher在修改老师记录时,我们对老师去过的城市进行CRUD操作时,要判断该老师去过的城市是否已经有学生了,有的话,就不允许删除这个对应的城市。

建立基本数据模型

基本说明:

  • 一位老师会去多个城市;
  • 一个城市里会有多位老师;

teachers 表

字段名 类型 备注
id unsigned integer 教师编号
name varchar(255) 教师姓名
subject varchar(255) 教授科目

对应迁移表:

Schema::create('teachers', function (Blueprint $table) {
    $table->increments('id');
    $table->string('name');
    $table->string('subject');
    $table->timestamps();
});

城市 表

字段名 类型 备注
id unsigned integer city编号
name varchar(255) 城市名

对应迁移表:

Schema::create('cities', function (Blueprint $table) {
    $table->increments('id');
    $table->string('name');
    $table->timestamps();
});

模型关联:

teacher_city_pivot 表

字段名 类型 备注
id unsigned integer id
teacher_id unsigned integer 对应教师表id
city_id unsigned integer 对应城市表id

对应迁移表:

Schema::create('teacher_city_pivot', function(Blueprint $table){
    $table->increments('id');
    $table->unsignedInteger('teacher_id');
    $table->unsignedInteger('city_id');
    $table->unique(['teacher_id', 'city_id']); // 同一位老师对应同一城市只能出现一次;
});

默认:中间表中的teacher_id 对应着teachers表中的id,city_id对应cities表的id。

多对多 pivot 对应关系

模型关联:
多对多关联时,采用中间表pivot(见下方的teacher_city_pivot表连接;
在教师表(teachers)对应的model中我们需要通过pivot表关联城市表(cities):

 class Teacher extends Model
{
    protected $fillable = ['name', 'subject'];

    public function cities()
    {
        // 第一个参数是教师对应的城市模型,第二个参数是中间表名;
        return $this->belongsToMany('App\Models\City','teacher_city_pivot');
    }
}

在城市表(cities)对应的model中我们同样需要通过pivot表关联老师表(teachers):

 class City extends Model
{
    protected $fillable = ['name'];

    public function teacher()
    {
        // 第一个参数是城市对应的教师模型,第二个参数是中间表名;
        return $this->belongsToMany('App\Models\Teacher','teacher_city_pivot');
    }
}

进入tinker中,先分别新建一个教师信息和一个城市信息;

➜  Laravel git:(master) ✗ php artisan tinker;
Psy Shell v0.8.11 (PHP 7.1.7 — cli) by Justin Hileman
>>> $t = App\Models\Teacher::find(1);
=> null
>>> $t = App\Models\Teacher::create(['name'=>'Miss Li', 'subject'=>'Enlish']);
=> App\Models\Teacher {#743
     name: "Miss Li",
     subject: "Enlish",
     updated_at: "2017-09-21 16:26:20",
     created_at: "2017-09-21 16:26:20",
     id: 1,
   }
>>> $s = App\Models\City::create(['name'=>'Loo']);
=> App\Models\City {#737
     name: "HangZhou",
     updated_at: "2017-09-21 16:26:49",
     created_at: "2017-09-21 16:26:49",
     id: 1,
   }

然后我们再来测试他们之间的关联是否正常建立:

>>> $t->cities()->attach(1);
=> null
>>> $t = App\Models\Teacher::find(1);
=> App\Models\Teacher {#749
     id: 1,
     name: "Miss Li",
     subject: "Enlish",
     created_at: "2017-09-21 16:26:20",
     updated_at: "2017-09-21 16:26:20",
   }
>>> $t->cities
=> Illuminate\Database\Eloquent\Collection {#739
     all: [
       App\Models\City {#745
         id: 1,
         name: "Loo",
         created_at: "2017-09-21 16:26:49",
         updated_at: "2017-09-21 16:26:49",
         pivot: Illuminate\Database\Eloquent\Relations\Pivot {#750
           teacher_id: 1,
           city_id: 1,
         },
       },
     ],
   }

通过上面的操作,我们可以肯定他们之间的关联是非常ok的,同时可以很清楚的知道每次链式操作最终返回对象的类的出处,从而更加快捷的去参阅API文档。在建立关联的时候我们用到了attach()方法,并且传入了一个整型参数,它对应着这位老师准备要教的一名新学生的id。
如这位老师飞多个城市,我们可以传入一个数组作为参数attach([1,2,3]);

多对多 pivot 非默认主键对应关系

现在我们来回答第一个问题:如果表的主键不是默认自增id怎么办?
通过这个问题,我们首先想想,如果学校辞退了20名老师(编号40~60),然后管理员把他们都删掉了。校长老大还想使用40到60的编号,在默认自增id就会对应不上了。此时我们就需要新增一个字段来标识教师编号了,同时这个编号也是我们的主键了,所以我们需要进行以下几个步骤来解决一些问题。

  1. 在Teacher模型对应的迁移文件中加上:$table->unsignedInteger('teachers_id');
  2. 在Teacher模型中加上:protected $primaryKey = 'teachers_id';
  3. 执行php artisan migrate:refresh迁移命令。
  4. 进入tinker中,看看新的对应关系是否正常建立。

同理,我们可以通过上述方法解决:学生的编号不是自增id

pivot 中一列对应多表(多对多和一对多示例)

现在我们再来看看更加复杂的应用。

  • 城市与教师:N : M
  • 城市与学生:N : M
  • 城市与家长:N : M
  • 教师与学生:1 : N
  • 学生与家长:1 : N

新的pivot表我们变成下面这样,通过观察下面的表结构和对应的记录,不难发现上面的对应关系中的前三种已经反映出来,同时表的设计还考虑到数据的冗余。这种冗余可以体现为2种表设计,

  • 其一,将城市,教师,学生,家长表的主键id都放到同一张表(id,city_id,teacher_id,student_id,parent_id),在实际的数据存储中会发现很多记录中的很多字段是没有数据的;比如我们想将城市与教师关联,就有一条唯一记录,学生和加载id在这条记录中是不会有数据的。(不推荐)
  • 其二,城市和教师、学生和家长表之间各建立一个中间表。这也是我们平时用的比较多的一种设计方式。(本文开篇时所提到的常规方法,具体采用哪一种就见仁见智了^_^)

因为对应关系发生了变化,前面的 pivot 表我们也更新名称为 city_type_bind_pivot

字段名 类型 备注
id unsigned integer id
city_source_id unsigned integer 城市编号
bind_id unsigned integer 对应bind_type中的非默认主键id
bind_type enum 对应多张表,可取值为:teacher, student, parent

bind_id 和 bind_type 共同确定唯一一条记录:

id city_source_id bind_id bind_type
1 1 1 teacher
2 1 2 teacher
3 1 1 student
3 1 2 student
4 1 1 parent

因为历史原因,需要修改教师表和学生表,我们的教师表中的默认id不能作为主键,需要新加一个字段teacher_id充当此作用。

字段名 类型 备注
id unsigned integer id
teacher_id unsigned integer 教师编号
name varchar(255) 教师姓名
subject varchar(255) 教授科目

同样,学生表也是如此:

字段名 类型 备注
id unsigned integer id
student_id unsigned integer 学生编号
teacher_id unsigned integer 教师编号
name varchar(255) 学生姓名

另外,我们又想将学生的家长们也纳入系统中来,学生有什么问题的时候可以随时联系到他们。

字段名 类型 备注
id unsigned integer id
student_id unsigned integer 学生编号
parent_id unsigned integer 家长编号
name varchar(255) 家长姓名

城市表:

字段名 类型 备注
id unsigned integer id
city_id unsigned integer 城市id
name varchar(255) 城市名

接下来,我们再在model中更新或添加关联关系:
City model:

<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;

class City extends Model
{
    protected $fillable = ['name', 'city_id', ];

    protected $primaryKey = 'city_id';

    public function teachers(){
        return $this->belongToMany('App\Models\Teacher', 'city_type_bind_pivot', 'city_source_id', 'bind_id')
            ->withPivot('city_source_id', 'bind_id', 'bind_type')
            ->wherePivot('bind_type', '=', 'teacher');
    }

        public function students(){
        return $this->belongToMany('App\Models\Student', 'city_type_bind_pivot', 'city_source_id', 'bind_id')
            ->withPivot('city_source_id', 'bind_id', 'bind_type')
            ->wherePivot('bind_type', '=', 'student');
    }

        public function parents(){
        return $this->belongToMany('App\Models\Teacher', 'city_type_bind_pivot', 'city_source_id', 'bind_id')
            ->withPivot('city_source_id', 'bind_id', 'bind_type')
            ->wherePivot('bind_type', '=', 'parent');
    }
}

进入tinker:

$city = App\Models\City::find(1)
>>> $city->teachers()->toSql()
=> "select * from `teachers` inner join `city_type_bind_pivot` 
on `teachers`.`teacher_id` = `city_type_bind_pivot`.`bind_id` 
where `city_type_bind_pivot`.`city_source_id` = ? 
and `city_type_bind_pivot`.`bind_type` = ?"
>>>

然后我们可以对比分析:上面的 teachers() 方法,
一个城市对应多位老师:belongsToMany;
第一个参数:与城市模型对应的 教师模型;
第二个参数:中间表名;
第三个参数:中间表中与城市关联的外键;(即当前模型对应的city_id),体现在这一句:
where city_type_bind_pivot.city_source_id = ? 这里的?就是City模型的当前实例对应的city_id。
第四个参数:中间表中与当前模型对应教师表关联的外键;体现在这一句:
on teachers.teacher_id = city_type_bind_pivot.bind_id

另外,->wherePivot('bind_type', '=', 'brand');就体现在sql语句的and city_type_bind_pivot.bind_type = ? 这句上面了,?就是wherePivot方法中的参数值brand
最后说明下->withPivot('city_source_id', 'bind_id', 'bind_type') 这句的作用是将中间表的某些字段加入到对应的模型中,方便在数据展示时使用。

家下来我们再看看在教师模型中如何对应关联上城市:

public function cities(){
    return $this->belongToMany(
    'App\Models\City',
    'city_type_bind_pivot', // pivot table name
    'bind_id',              // where
    'city_source_id',       // on 
    'city_id')              // 如果没有在City中使用$primaryKey来指定主键的话,这个就必须加上了。
            ->withPivot('source_type', 'bind_id', 'bind_type')
            ->wherePivot('bind_type', '=', 'teacher');
}

再次进入tinker,来观察下对应的sql:

>>> $teacher = App\Models\Teacher::find(1)
>>> $teacher->cities()->toSql()x
=> "select * from `city` inner join `city_type_bind_pivot` 
on `city`.`city_id` = `source_type_binds`.`city_source_id` 
where `city_type_bind_pivot`.`bind_id` = ? 
and `city_type_bind_pivot`.`bind_type` = ?"

因为城市对应学生和家长的关联方式和教师类似,就不再累述。

修改并删除不需要的元素

前端提供一个select选择框(注意该select框要调整为多选,同时name='city_source_id[]'),将所有城市列举出来,数据提交后。
新建teacher记录的同时添加其对应的城市,下面的操作是将记录插入到pivot中间表中:

foreach ($all_input['city_source_id'] as $key => $mode_id){
    $flag->cities()->attach($key,
    ['city_source_id' => $mode_id,
    'bind_id' => $all_input['teacher_id'], //前端文本框输入的(唯一教师编号)
    'bind_type' => 'teacher']
    );
}

修改并删除:解决: 因为我们有 teacher 表与 city 的关系为N:M,Teacher在修改老师记录时,我们对老师去过的城市进行CRUD操作时,要判断该老师去过的城市是否已经有学生了,有的话,就不允许删除这个对应的城市。

        /**
         * 追加:判断是否已经添加;
         * 如果当前的教师添加的城市在city表中不存在,就添加进去。
         */
        foreach ($all_input['city_source_id'] as $key => $mode_id){
            if(!$cur_teacher->cities->contains('city_id', $mode_id)){
                $cur_teacher->cities()->attach($key, ['city_source_id' => $mode_id, 'bind_id' => $all_input['brand_id'], 'bind_type' => 'brand']);
            }
        }
        // 删除 teacher 对应的 cities 中 已经在$all_input['city_source_id']不存在的内容;
                // 但是还需要判断:该教师对应的某城市中没有对应的学生存在;
        $mode_ids = $cur_teacher->cities()->pluck('city_id')->toArray();
        $del_mids = array_diff($mode_ids, $all_input['city_source_id']);
        foreach ($del_mids as $city_id) {
            $students = Student::getStudentsByCityAndTeacher($cur_teacher->teacher_id, $city_id);
            if(empty($students)) {
                $cur_teacher->cities()->detach([$city_id]);
            }
        }

在Student和Parent对应的Controller中,也可以如法炮制,不再累述。

了解Eloquent 其他常用方法

https://learnku.com/docs/laravel/5.3/eloquent-relationships#更新关联

总结

Laravel 帮助我们封装了很多数据库层面的东西,但是在学习Eloquent,经常在建立模型关系时,如果感觉不知所措,不知道为啥得到的数据为什么总是不能达到自己的要求时,可以直接进入tinker中调试模型及其方法对应的sql语句结构。此时此刻,我们不需要view,不需要controller,只需要有迁移表和对应的模型即可。

本作品采用《CC 协议》,转载必须注明作者和本文链接
努力是不会骗人的!
《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
《L02 从零构建论坛系统》
以构建论坛项目 LaraBBS 为线索,展开对 Laravel 框架的全面学习。应用程序架构思路贴近 Laravel 框架的设计哲学。
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!