多對多 + 非預設主鍵 + 中間表某列資料對應多表--稍稍複雜應用

storefee發表於2017-09-11

前言

  • 如果你是剛開始學習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,只需要有遷移表和對應的模型即可。

努力是不會騙人的!

相關文章