Eloquent ORM - 多对多关联

接着要示范如何用 Eloquent 建立多对多关联的查询,目标帮目前的 Todo 建立 Tag 标签,一个 Todo 可以有多个 Tag ,一个 Tag 底下有多个 Todo。

多对多资料结构

建立多对多关联需要多一张枢钮资料表做中介。

建立 migration

首先建立对应的 Model 顺便建立 migration

sail artisan make:model Tag  -mcr
sail artisan make:model TodoTag
sail artisan make:migration create_todo_tag_table --create todo_tag

建立 TodoTag 时不带入 -m 建立 migration 是因为会变成建立 todo_tags 资料表,多一个 s。
当然建立後手动改成 todo_tag 也行。

Eloquent 在查询多对多关联时会将两个表单的名称连接作为中介资料表的预设名称,不过也可以在定义关联时自订中介资料表的名称,所以不用太纠结。

migration 内容

Tag 的部分多加上一个 name 栏位就好

/database/migrations/XXXX_XX_XX_XXXXXX_create_tags_table.php

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

TodoTag 则要加上两个外键

/database/migrations/XXXX_XX_XX_XXXXXX_create_todo_tag_table.php

    Schema::create('todo_tag', function (Blueprint $table) {
        $table->id();
        $table->foreignId('todo_id')->constrained();
        $table->foreignId('tag_id')->constrained();
        $table->timestamps();
    });

belongsToMany 关联

/app/Models/Todo.php

@@ -5,6 +5,7 @@ namespace App\Models;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use App\Models\User;
+use App\Models\Tag;
 
 class Todo extends Model
 {
@@ -18,4 +19,10 @@ class Todo extends Model
     {
         return $this->belongsTo(User::class);
     }
+
+    public function tags()
+    {
+        return $this->belongsToMany(Tag::class);
+         
+    }
 }

多对多关联的方法是用 belongsToMany ,注意跟 hasMany 的差别。

hasMany 用於一对多,会预设对方表单有关联自己 id 的外键来做查询。
belongsToMany 则会经由中介资料表去查询。

前面提到 belongsToMany 会预设中介资料表的名称,不过也可以自订。

$this->belongsToMany(Tag::class,'todo_tag');

再进一步可以自订关联用的栏位名称

// belongsToMany(目标表单名称,中介表单名称,中介表单上参照自己的外键,中介表单上参照目标的外键)
$this->belongsToMany(Tag::class,'todo_tag','todo_id','tag_id');

// belongsToMany(目标表单名称,中介表单名称,中介表单上参照自己的外键,中介表单上参照目标的外键,自己的关联键,目标的关联键)
$this->belongsToMany(Tag::class,'todo_tag','todo_id','tag_id','id','id');

反向定义 Tag 对 Todo 的关联是一样的写法

/app/Models/Tag.php

@@ -5,6 +5,7 @@ namespace App\Models;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
+use App\Models\Todo;
 
 class Tag extends Model
 {
    use HasFactory;

+   public function todos()
+   {
+       return $this->belongsToMany(Todo::class,'todo_tag');
+   }
 }

withTimeStamps

当我们建立 todo_tag 表单时有加上 timeStamp 栏位,不过经由 belongsToMany 关联建立资料时,预设是不会帮中介表单加上 timeStamp 的,也就是说 todo_tag 的 create_at 跟 update_at 会为空。

如果想要帮中介表单加上 timeStamp ,要加上 withTimeStamps 方法

public function tags()
{
    return $this->belongsToMany(Tag::class,'todo_tag')->withTimestamps();

}

多对多关联新增资料

首先加上路由跟 addTag 方法。

/routes/web.php

@@ -31,7 +32,9 @@ Route::get('/dashboard', [TodoController::class, 

+Route::post('todos/{todo}/tag', [TodoController::class,'addTag'])->name('todos.addTag');

/app/Http/Controllers/TodoController.php

@@ -110,4 +107,19 @@ class TodoController extends Controller
         // Todo::whereIn('id', $todoIds )->delete(); 
         return $count;
     }
+
+    public function addTag(Request $request,Todo $todo)
+    {
+        $data = $request->all();
+
+        $tag = Tag::firstOrCreate(
+            ['name' => $data['name']] 
+        );
+ 
+        $todo->tags()->attach($tag->id);
+    }
 }

firstOrCreate / firstOrNew

我们一样可以用 $todo->tags()->create() 方法建立与目前 todo 关联的 tag 资料,不过这样很有可能就会建立 name 相同的 tag ,所以这边将做法改为先确认是否已经有同名称的 tag ,没有的话就新建,有的话就读取,然後再建立跟 todo 的关联。

firstOrCreate 方法用於确认是否有对应查询的资料,有的话就取出第一笔,没有的话就根据搜寻条件建立资料,同时也能追加搜寻条件以外的资料进行新增。

$flight = Flight::firstOrCreate(
    ['name' => 'London to Paris'],  //搜寻的条件
    ['delayed' => 1, 'arrival_time' => '11:30'] // 当找不到资料而进行新增时,追加的资料
);

如果不想马上写入资料库,也可用 firstOrNew 方法,在找不到对应资料时先建立 Model Instance。

$flight = Flight::firstOrNew(
    ['name' => 'Tokyo to Sydney'],
    ['delayed' => 1, 'arrival_time' => '11:30']
);

attach

当新增或查询到目标的 tag 之後,就能用 belongsToMany 的 attach 方法建立关联。

$todo->tags()->attach($tag->id);

注意这边 attach 的参数是目标的 id ,也就是关联栏位的资料,而不是整个 Model Instance。

如果传入 id 阵列的话 attach 也能用来批次新增关联

$todo->tags()->attach([1,2,3]);

detach

如果要移除关联,一样藉由 id 进行 detach

// 移除一个关联
$todo->tags()->detach(1);

// 移除多个关联
$todo->tags()->detach([1,2,3]);

以此来建立移除 tag 的方法

/routes/web.php

@@ -31,7 +32,9 @@ Route::get('/dashboard', [TodoController::class, 

+ Route::put('todos/{todo}/tag', [TodoController::class,'removeTag'])->name('todos.removeTag');

/app/Http/Controllers/TodoController.php

@@ -110,4 +107,24 @@ class TodoController extends Controller
+
+    public function removeTag(Request $request,Todo $todo)
+    {
+        $data = $request->all(); 
+        $todo->tags()->detach($data['tagId']);
+    }
 }

读取资料

因为是要跟着 todo 清单一起显示的,就在读取 todos 资料时用 with 带上 tag 清单。

/app/Http/Controllers/TodoController.php

@@ -16,7 +17,7 @@ class TodoController extends Controller
     public function index()
     {  
          return inertia('Dashboard', [
-            'todos' => Auth::user()->todos,
+            'todos' => Auth::user()->todos()->with('tags')->get(),
         ]);
     }

更新画面

画面的变更部分就做参考吧,都跟之前一样发送请求後刷新 todo 清单。

/resources/js/Pages/Dashboard.js

@@ -13,6 +13,8 @@ import {
   ListItemIcon,
   ListItemButton,
   Checkbox,
+  Chip,
+  Stack,
 } from "@mui/material";
 import { Head, Link, useForm } from "@inertiajs/inertia-react";
 import { Inertia } from "@inertiajs/inertia";
@@ -29,7 +31,12 @@ export default function Dashboard(props) {
   );
 
   const [todoList, setTodoList] = useState(
-    todos.map((todo) => ({ ...todo, editing: false, checked: false }))
+    todos.map((todo) => ({
+      ...todo,
+      editing: false,
+      checked: false,
+      tagging: false,
+    }))
   );
 
   const handleChange = (event) => {
@@ -114,6 +121,43 @@ export default function Dashboard(props) {
     );
   };
 
+  const toggleTagging = (todoId) => {
+    const newList = todoList.map((todo) =>
+      todo.id === todoId
+        ? { ...todo, tagging: !todo.tagging }
+        : { ...todo, tagging: false }
+    );
+    setTodoList(newList);
+  };
+
+  const requestAddTag = (todoId, name) => {
+    Inertia.post(
+      route("todos.addTag", { id: todoId }),
+      {
+        name,
+      },
+      {
+        onFinish: (visit) => {
+          Inertia.reload({ only: ["todos"] });
+        },
+      }
+    );
+  };
+
+  const requestRemoveTag = (todoId, tagId) => {
+    Inertia.put(
+      route("todos.removeTag", { id: todoId }),
+      {
+        tagId,
+      },
+      {
+        onFinish: (visit) => {
+          Inertia.reload({ only: ["todos"] });
+        },
+      }
+    );
+  };
+
   return (
     <Authenticated
       auth={props.auth}
@@ -169,9 +213,9 @@ export default function Dashboard(props) {
             )}
           </Grid>
         </form>
-        <Box sx={{ width: "100%", maxWidth: 360 }}>
-          <List>
-            {todoList.map((item) => (
+        <List>
+          {todoList.map((item) => (
+            <Stack direction='row' spacing={1} alignItems='center'>
               <ListItem
                 button
                 key={item.id}
@@ -223,9 +267,38 @@ export default function Dashboard(props) {
                   </ListItemButton>
                 )}
               </ListItem>
-            ))}
-          </List>
-        </Box>
+              {item.tags.map((tag) => (
+                <Chip
+                  label={tag.name}
+                  onDelete={() => requestRemoveTag(item.id, tag.id)}
+                />
+              ))}
+              {!item.tagging ? (
+                <Chip
+                  label='Add Tag'
+                  variant='outlined'
+                  onClick={() => toggleTagging(item.id)}
+                />
+              ) : (
+                <Chip
+                  component={() => (
+                    <TextField
+                      autoFocus
+                      placeholder={item.name}
+                      variant='standard'
+                      onBlur={() => toggleTagging(item.id)}
+                      onKeyDown={(e) => {
+                        if (e.key == "Enter") {
+                          requestAddTag(item.id, e.target.value);
+                        }
+                      }}
+                    />
+                  )}
+                />
+              )}
+            </Stack>
+          ))}
+        </List>
       </Container>
     </Authenticated>
   );


<<:  认识CSS(八):CSS BOX模型

>>:  Day18 使用React建立手风琴菜单(accordion menu)

羊肉炉吃到饱

《本草纲目》有记载:羊肉具有「暖中补虚,补中益气,开胃健力,治虚劳恶冷,五劳七伤」的功效。 昨天既然...

[Day 14] Leetcode 115. Distinct Subsequences (C++)

前言 今日挑战的题目是115. Distinct Subsequences,虽然是hard,但因为有...

DAY10 - websocket前端实作-以vue.js为例

今天我们就来讲一下,当我们专案中确定会导入websocket了,前端的工作流程会是怎样,要怎麽跟後端...

模型初始化方法问题

在建立模型中有一项权重初始化方法,我看过有人这样写kernel_initializer='norma...

如何免费写出好的 App description

关於如何写 App 的 description,我目前有两个小技巧可以分享: 关键字优化 用Deep...