update

2881099
2022-05-04 23:53:42 +08:00
parent 4476f30f36
commit cfb455bc6e
3 changed files with 145 additions and 121 deletions

@@ -1,128 +1,147 @@
[中文](%e8%81%94%e7%ba%a7%e4%bf%9d%e5%ad%98) | **English**
The cascade save function can save the one-to-many or many-to-many navigation properties when saving the object. This document introduces cascading saving to avoid misuse by developers.
## SaveMany Savemany save manually
## One-to-Many Cascade Saving
Method 1: Save intact, compare the existing data in the table, and calculate the added, modified, and deleted data
Save completely, compare the existing data in the table, and calculate the execution of addition, modification and deletion.
```csharp
var repo = fsql.GetRepository<T>();
repo.Insert(item);
repo.SaveMany(item, "Childs");
```
- It is possible to delete the existing data in the table, confirm?
- When the Childs property is Empty, delete all the table data of Childs in the item, confirm?
- When saving Topics, the subordinate collection properties of Childs\[0-..\] are not saved, only the current level of Topics. Confirm?
Method 2: Additional save, do not delete the existing data in the table
```csharp
var repo = fsql.GetRepository<T>();
repo.DbContextOptions.EnableCascadeSave = true; //Need to be opened manually
repo.Insert(item);
```
- Do not delete the existing data in the table, confirm?
- When the Childs property is Empty, do nothing, confirm?
- When saving Childs, the subordinate collection properties of Childs\[0-..\] will also be saved, _down 18 levels_, confirm?
> _Down 18 levels_ is, the navigation properties that exist in the navigation properties, in which there are navigation properties.
> The navigation properties of the navigation properties are retrieved layer by layer, and the InsertOrUpdate operation is performed together.
## Many-to-Many Cascade Saving
There is only one mechanism: complete preservation.
> Enabling the `EnableCascadeSave` option or `SaveMany` is a complete save.
---
Test 1: Additional Save OneToMany
```csharp
[Table(Name = "EAUNL_OTMP_CT")]
class CagetoryParent {
class Cagetory
{
public Guid Id { get; set; }
public string Name { get; set; }
public Guid ParentId { get; set; }
[Navigate(nameof(ParentId))]
public List<CagetoryParent> Childs { get; set; }
public List<Cagetory> Childs { get; set; }
}
//item = ...;
var repo = fsql.GetRepository<Cagetory>();
repo.Insert(item);
repo.SaveMany(item, "Childs");
```
-[OneToMany] and [ManyToMany] navigation attributes are supported
-Save only children, not recursive tracing down
-When children is empty, delete all children table data existing in item, and confirm?
-Advantages: simple mechanism, good control and safety
-Disadvantages: not intelligent
## EnableCascadeSave DbContext/Repository
DbContext/Repository EnableCascadeSave enables recursive tracing of the OneToOne, OneToMany, and ManyToMany navigation properties of objects when they are saved. This document describes the mechanism to prevent misuse.
1. OneToOne cascade save
> v3.2.606 + support, and support [cascade deletion function](https://github.com/dotnetcore/FreeSql/wiki/%e5%88%a0%e9%99%a4#ibaserepository-%E7%BA%A7%E8%81%94%E5%88%A0%E9%99%A4)
2. OneToMany appends or updates the sub table without deleting the existing data of the sub table
```c#
var repo = fsql. GetRepository<Cagetory>();
repo. DbContextOptions. EnableCascadeSave = true; // Manual opening required
repo. Insert(item);
```
-Do not delete the existing data of children sub table. Are you sure?
-When the children attribute is empty, do not do anything. Confirm?
-When you save children, you will also save the child set attributes of children \ [0 -.. \]. Go down to 18 layers and confirm?
>For example, in the [type] table, there is set attribute [article] below and set attribute [comment] below [article].
>When saving the [type] table object, it will retrieve the set attribute [article], and then if the [article] is saved, it will continue to retrieve the set attribute [comment]. Do insertorupdate operation together.
3. ManyToMany completely compares and saves the intermediate table and appends the external table
Compare and save the intermediate table completely, compare the existing data of the [many to many] intermediate table, and calculate the execution of addition, modification and deletion.
Append external tables, only append without updating.
-This table song
-External table tag
-Intermediate table Songtag
---
Test 1: append and save OneToMany
```csharp
[Fact]
public void TestOneToManyParent() {
var repo = fsql.GetRepository<CagetoryParent>();
public void TestOneToManyParent()
{
var repo = fsql.GetRepository<Cagetory>();
repo.DbContextOptions.EnableCascadeSave = true;
var cts = new[] {
new CagetoryParent
var cts = new[]
{
new Cagetory
{
Name = "Category1",
Childs = new List<CagetoryParent>(new[]
Name = "class1",
Childs = new List<Cagetory>(new[]
{
new CagetoryParent { Name = "Category1_1" },
new CagetoryParent { Name = "Category1_2" },
new CagetoryParent { Name = "Category1_3" }
new Cagetory { Name = "class1_1" },
new Cagetory { Name = "class1_2" },
new Cagetory { Name = "class1_3" }
})
},
new CagetoryParent
new Cagetory
{
Name = "Category2",
Childs = new List<CagetoryParent>(new[]
Name = "class2",
Childs = new List<Cagetory>(new[]
{
new CagetoryParent { Name = "Category2_1" },
new CagetoryParent { Name = "Category2_2" }
new Cagetory { Name = "class2_1" },
new Cagetory { Name = "class2_2" }
})
}
};
repo.Insert(cts);
//Perform table creation and insert data:
//INSERT INTO "EAUNL_OTMP_CT"("Id", "Name", "ParentId") VALUES('5d90afcb-ed57-f6f4-0082-cb6b78eaaf9f', 'Category1', '00000000-0000-0000-0000-000000000000'), ('5d90afcb-ed57-f6f4-0082-cb6c5b531b3e', 'Category2', '00000000-0000-0000-0000-000000000000')
//INSERT INTO "EAUNL_OTMP_CT"("Id", "Name", "ParentId") VALUES('5d90afcb-ed57-f6f4-0082-cb6d0c1c5f1a', 'Category1_1', '5d90afcb-ed57-f6f4-0082-cb6b78eaaf9f'), ('5d90afcb-ed57-f6f4-0082-cb6e74bd8eef', 'Category1_2', '5d90afcb-ed57-f6f4-0082-cb6b78eaaf9f'), ('5d90afcb-ed57-f6f4-0082-cb6f6267cc5f', 'Category1_3', '5d90afcb-ed57-f6f4-0082-cb6b78eaaf9f'), ('5d90afcb-ed57-f6f4-0082-cb7057c41d46', 'Category2_1', '5d90afcb-ed57-f6f4-0082-cb6c5b531b3e'), ('5d90afcb-ed57-f6f4-0082-cb7156e0375e', 'Category2_2', '5d90afcb-ed57-f6f4-0082-cb6c5b531b3e')
cts[0].Name = "Category11";
//To create a table and insert data:
//INSERT INTO "Cagetory"("Id", "Name", "ParentId") VALUES('5d90afcb-ed57-f6f4-0082-cb6b78eaaf9f', 'class1', '00000000-0000-0000-0000-000000000000'), ('5d90afcb-ed57-f6f4-0082-cb6c5b531b3e', 'class2', '00000000-0000-0000-0000-000000000000')
//INSERT INTO "Cagetory"("Id", "Name", "ParentId") VALUES('5d90afcb-ed57-f6f4-0082-cb6d0c1c5f1a', 'class1_1', '5d90afcb-ed57-f6f4-0082-cb6b78eaaf9f'), ('5d90afcb-ed57-f6f4-0082-cb6e74bd8eef', 'class1_2', '5d90afcb-ed57-f6f4-0082-cb6b78eaaf9f'), ('5d90afcb-ed57-f6f4-0082-cb6f6267cc5f', 'class1_3', '5d90afcb-ed57-f6f4-0082-cb6b78eaaf9f'), ('5d90afcb-ed57-f6f4-0082-cb7057c41d46', 'class2_1', '5d90afcb-ed57-f6f4-0082-cb6c5b531b3e'), ('5d90afcb-ed57-f6f4-0082-cb7156e0375e', 'class2_2', '5d90afcb-ed57-f6f4-0082-cb6c5b531b3e')
cts[0].Name = "class11";
cts[0].Childs.Clear();
cts[1].Name = "Category22";
cts[1].Name = "class22";
cts[1].Childs.Clear();
repo.Update(cts);
//UPDATE "EAUNL_OTMP_CT" SET "Name" = CASE "Id"
//WHEN '5d90afcb-ed57-f6f4-0082-cb6b78eaaf9f' THEN 'Category11'
//WHEN '5d90afcb-ed57-f6f4-0082-cb6c5b531b3e' THEN 'Category22' END
//UPDATE "Cagetory" SET "Name" = CASE "Id"
//WHEN '5d90afcb-ed57-f6f4-0082-cb6b78eaaf9f' THEN 'class11'
//WHEN '5d90afcb-ed57-f6f4-0082-cb6c5b531b3e' THEN 'class22' END
//WHERE ("Id" IN ('5d90afcb-ed57-f6f4-0082-cb6b78eaaf9f','5d90afcb-ed57-f6f4-0082-cb6c5b531b3e'))
//After Childs.Clear, the delete sub-collection operation is not executed, indicating that a complete comparison has not been made
cts[0].Name = "Category111";
//Childs.Clear After that, the operation of deleting subsets was not performed, indicating that no complete comparison was made
cts[0].Name = "class111";
cts[0].Childs.Clear();
cts[0].Childs.Add(new CagetoryParent { Name = "Category1_33" });
cts[1].Name = "Category222";
cts[0].Childs.Add(new Cagetory { Name = "class1_33" });
cts[1].Name = "class222";
cts[1].Childs.Clear();
cts[1].Childs.Add(new CagetoryParent { Name = "Category2_22" });
cts[1].Childs.Add(new Cagetory { Name = "class2_22" });
repo.Update(cts);
//UPDATE "EAUNL_OTMP_CT" SET "Name" = CASE "Id"
//WHEN '5d90afcb-ed57-f6f4-0082-cb6b78eaaf9f' THEN 'Category111'
//WHEN '5d90afcb-ed57-f6f4-0082-cb6c5b531b3e' THEN 'Category222' END
//UPDATE "Cagetory" SET "Name" = CASE "Id"
//WHEN '5d90afcb-ed57-f6f4-0082-cb6b78eaaf9f' THEN 'class111'
//WHEN '5d90afcb-ed57-f6f4-0082-cb6c5b531b3e' THEN 'class222' END
//WHERE ("Id" IN ('5d90afcb-ed57-f6f4-0082-cb6b78eaaf9f','5d90afcb-ed57-f6f4-0082-cb6c5b531b3e'))
//INSERT INTO "EAUNL_OTMP_CT"("Id", "Name", "ParentId") VALUES('5d90afe8-ed57-f6f4-0082-cb725df546ea', 'Category1_33', '5d90afcb-ed57-f6f4-0082-cb6b78eaaf9f'), ('5d90afe8-ed57-f6f4-0082-cb7338a6214c', 'Category2_22', '5d90afcb-ed57-f6f4-0082-cb6c5b531b3e')
//INSERT INTO "Cagetory"("Id", "Name", "ParentId") VALUES('5d90afe8-ed57-f6f4-0082-cb725df546ea', 'class1_33', '5d90afcb-ed57-f6f4-0082-cb6b78eaaf9f'), ('5d90afe8-ed57-f6f4-0082-cb7338a6214c', 'class2_22', '5d90afcb-ed57-f6f4-0082-cb6c5b531b3e')
}
```
Test 2: Additional Save ManyToMany
---
Test 2: Full save ManyToMany
```csharp
[Table(Name = "EAUNL_MTM_SONG")]
class Song {
class Song
{
public Guid Id { get; set; }
public string Name { get; set; }
public List<Tag> Tags { get; set; }
}
[Table(Name = "EAUNL_MTM_TAG")]
class Tag {
class Tag
{
public Guid Id { get; set; }
public string TagName { get; set; }
public List<Song> Songs { get; set; }
}
[Table(Name = "EAUNL_MTM_SONGTAG")]
class SongTag {
class SongTag
{
public Guid SongId { get; set; }
public Song Song { get; set; }
public Guid TagId { get; set; }
@@ -130,18 +149,20 @@ class SongTag {
}
[Fact]
public void TestManyToMany() {
var tags = new[] {
new Tag { TagName = "Pop" },
new Tag { TagName = "Modern music" },
new Tag { TagName = "Next-generation music" },
new Tag { TagName = "Rock" }
public void TestManyToMany()
{
var tags = new[]
{
new Tag { TagName = "pop music" },
new Tag { TagName = "the post-80s generation" },
new Tag { TagName = "the post-00s generation" },
new Tag { TagName = "Rock music" }
};
var ss = new[]
{
new Song
{
Name = "Take Me To Your Heart.mp3",
Name = "love you forever.mp3",
Tags = new List<Tag>(new[]
{
tags[0], tags[1]
@@ -149,7 +170,7 @@ public void TestManyToMany() {
},
new Song
{
Name = "Kiss Me More.mp3",
Name = "Li Bai.mp3",
Tags = new List<Tag>(new[]
{
tags[0], tags[2]
@@ -159,49 +180,49 @@ public void TestManyToMany() {
var repo = fsql.GetRepository<Song>();
repo.DbContextOptions.EnableCascadeSave = true;
repo.Insert(ss);
//INSERT INTO "EAUNL_MTM_SONG"("Id", "Name") VALUES('5d90fdb3-6a6b-2c58-00c8-37974177440d', 'Take Me To Your Heart.mp3'), ('5d90fdb3-6a6b-2c58-00c8-37987f29b197', 'Kiss Me More.mp3')
//INSERT INTO "EAUNL_MTM_TAG"("Id", "TagName") VALUES('5d90fdb7-6a6b-2c58-00c8-37991ead4f05', 'Pop'), ('5d90fdbd-6a6b-2c58-00c8-379a0432a09c', 'Modern music')
//INSERT INTO "EAUNL_MTM_SONGTAG"("SongId", "TagId") VALUES('5d90fdb3-6a6b-2c58-00c8-37974177440d', '5d90fdb7-6a6b-2c58-00c8-37991ead4f05'), ('5d90fdb3-6a6b-2c58-00c8-37974177440d', '5d90fdbd-6a6b-2c58-00c8-379a0432a09c')
//INSERT INTO "EAUNL_MTM_TAG"("Id", "TagName") VALUES('5d90fdcc-6a6b-2c58-00c8-379b5af59d25', 'Next-generation music')
//INSERT INTO "EAUNL_MTM_SONGTAG"("SongId", "TagId") VALUES('5d90fdb3-6a6b-2c58-00c8-37987f29b197', '5d90fdb7-6a6b-2c58-00c8-37991ead4f05'), ('5d90fdb3-6a6b-2c58-00c8-37987f29b197', '5d90fdcc-6a6b-2c58-00c8-379b5af59d25')
//INSERT INTO "Song"("Id", "Name") VALUES('5d90fdb3-6a6b-2c58-00c8-37974177440d', 'love you forever.mp3'), ('5d90fdb3-6a6b-2c58-00c8-37987f29b197', 'Li Bai.mp3')
//INSERT INTO "Tag"("Id", "TagName") VALUES('5d90fdb7-6a6b-2c58-00c8-37991ead4f05', 'pop music'), ('5d90fdbd-6a6b-2c58-00c8-379a0432a09c', 'the post-80s generation')
//INSERT INTO "SongTag"("SongId", "TagId") VALUES('5d90fdb3-6a6b-2c58-00c8-37974177440d', '5d90fdb7-6a6b-2c58-00c8-37991ead4f05'), ('5d90fdb3-6a6b-2c58-00c8-37974177440d', '5d90fdbd-6a6b-2c58-00c8-379a0432a09c')
//INSERT INTO "Tag"("Id", "TagName") VALUES('5d90fdcc-6a6b-2c58-00c8-379b5af59d25', 'the post-00s generation')
//INSERT INTO "SongTag"("SongId", "TagId") VALUES('5d90fdb3-6a6b-2c58-00c8-37987f29b197', '5d90fdb7-6a6b-2c58-00c8-37991ead4f05'), ('5d90fdb3-6a6b-2c58-00c8-37987f29b197', '5d90fdcc-6a6b-2c58-00c8-379b5af59d25')
ss[0].Name = "Take Me To Your Heart.mp5";
ss[0].Name = "love you forever.mp5";
ss[0].Tags.Clear();
ss[0].Tags.Add(tags[0]);
ss[1].Name = "Kiss Me More.mp5";
ss[1].Name = "Li Bai.mp5";
ss[1].Tags.Clear();
ss[1].Tags.Add(tags[3]);
repo.Update(ss);
//UPDATE "EAUNL_MTM_SONG" SET "Name" = CASE "Id"
//WHEN '5d90fdb3-6a6b-2c58-00c8-37974177440d' THEN 'Take Me To Your Heart.mp5'
//WHEN '5d90fdb3-6a6b-2c58-00c8-37987f29b197' THEN 'Kiss Me More.mp5' END
//UPDATE "Song" SET "Name" = CASE "Id"
//WHEN '5d90fdb3-6a6b-2c58-00c8-37974177440d' THEN 'love you forever.mp5'
//WHEN '5d90fdb3-6a6b-2c58-00c8-37987f29b197' THEN 'Li Bai.mp5' END
//WHERE ("Id" IN ('5d90fdb3-6a6b-2c58-00c8-37974177440d','5d90fdb3-6a6b-2c58-00c8-37987f29b197'))
//SELECT a."SongId", a."TagId"
//FROM "EAUNL_MTM_SONGTAG" a
//FROM "SongTag" a
//WHERE (a."SongId" = '5d90fdb3-6a6b-2c58-00c8-37974177440d')
//DELETE FROM "EAUNL_MTM_SONGTAG" WHERE ("SongId" = '5d90fdb3-6a6b-2c58-00c8-37974177440d' AND "TagId" = '5d90fdbd-6a6b-2c58-00c8-379a0432a09c')
//INSERT INTO "EAUNL_MTM_TAG"("Id", "TagName") VALUES('5d90febd-6a6b-2c58-00c8-379c21acfc72', 'Rock')
//DELETE FROM "SongTag" WHERE ("SongId" = '5d90fdb3-6a6b-2c58-00c8-37974177440d' AND "TagId" = '5d90fdbd-6a6b-2c58-00c8-379a0432a09c')
//INSERT INTO "Tag"("Id", "TagName") VALUES('5d90febd-6a6b-2c58-00c8-379c21acfc72', 'Rock music')
//SELECT a."SongId", a."TagId"
//FROM "EAUNL_MTM_SONGTAG" a
//FROM "SongTag" a
//WHERE (a."SongId" = '5d90fdb3-6a6b-2c58-00c8-37987f29b197')
//DELETE FROM "EAUNL_MTM_SONGTAG" WHERE ("SongId" = '5d90fdb3-6a6b-2c58-00c8-37987f29b197' AND "TagId" = '5d90fdb7-6a6b-2c58-00c8-37991ead4f05' OR "SongId" = '5d90fdb3-6a6b-2c58-00c8-37987f29b197' AND "TagId" = '5d90fdcc-6a6b-2c58-00c8-379b5af59d25')
//INSERT INTO "EAUNL_MTM_SONGTAG"("SongId", "TagId") VALUES('5d90fdb3-6a6b-2c58-00c8-37987f29b197', '5d90febd-6a6b-2c58-00c8-379c21acfc72')
//DELETE FROM "SongTag" WHERE ("SongId" = '5d90fdb3-6a6b-2c58-00c8-37987f29b197' AND "TagId" = '5d90fdb7-6a6b-2c58-00c8-37991ead4f05' OR "SongId" = '5d90fdb3-6a6b-2c58-00c8-37987f29b197' AND "TagId" = '5d90fdcc-6a6b-2c58-00c8-379b5af59d25')
//INSERT INTO "SongTag"("SongId", "TagId") VALUES('5d90fdb3-6a6b-2c58-00c8-37987f29b197', '5d90febd-6a6b-2c58-00c8-379c21acfc72')
ss[0].Name = "Take Me To Your Heart.mp4";
ss[0].Name = "love you forever.mp4";
ss[0].Tags.Clear();
ss[1].Name = "Kiss Me More.mp4";
ss[1].Name = "Li Bai.mp4";
ss[1].Tags.Clear();
repo.Update(ss);
//DELETE FROM "EAUNL_MTM_SONGTAG" WHERE ("SongId" = '5d90fdb3-6a6b-2c58-00c8-37974177440d')
//DELETE FROM "EAUNL_MTM_SONGTAG" WHERE ("SongId" = '5d90fdb3-6a6b-2c58-00c8-37987f29b197')
//DELETE FROM "SongTag" WHERE ("SongId" = '5d90fdb3-6a6b-2c58-00c8-37974177440d')
//DELETE FROM "SongTag" WHERE ("SongId" = '5d90fdb3-6a6b-2c58-00c8-37987f29b197')
//UPDATE "EAUNL_MTM_SONG" SET "Name" = CASE "Id"
//WHEN '5d90fdb3-6a6b-2c58-00c8-37974177440d' THEN 'Take Me To Your Heart.mp4'
//WHEN '5d90fdb3-6a6b-2c58-00c8-37987f29b197' THEN 'Kiss Me More.mp4' END
//UPDATE "Song" SET "Name" = CASE "Id"
//WHEN '5d90fdb3-6a6b-2c58-00c8-37974177440d' THEN 'love you forever.mp4'
//WHEN '5d90fdb3-6a6b-2c58-00c8-37987f29b197' THEN 'Li Bai.mp4' END
//WHERE ("Id" IN ('5d90fdb3-6a6b-2c58-00c8-37974177440d','5d90fdb3-6a6b-2c58-00c8-37987f29b197'))
}
```
```

@@ -118,7 +118,6 @@ repo.Insert(new UserGroup
//INSERT INTO "user"("username", "password", "groupid") VALUES('admin01', 'pwd01', 1), ('admin02', 'pwd02', 1), ('admin03', 'pwd03', 1) RETURNING "id" as "Id", "username" as "Username", "password" as "Password", "groupid" as "GroupId"
//INSERT INTO "userext"("userid", "remark") VALUES(3, '用户备注01'), (4, '用户备注02'), (5, '用户备注03')
var groups = repo.Select
.IncludeMany(a => a.Users,
then => then.Include(b => b.UserExt))

@@ -21,7 +21,7 @@ repo.Insert(item);
repo.SaveMany(item, "Childs");
```
- 支持OneToMany】、【ManyToMany导航属性
- 支持 OneToManyManyToMany 导航属性
- 只保存 Childs不向下递归追朔
- 当 Childs 为 Empty 时,删除 item 存在的 Childs 所有表数据,确认?
- 优点:机制简单,好把控,安全
@@ -29,13 +29,13 @@ repo.SaveMany(item, "Childs");
## EnableCascadeSave 仓储级联保存
DbContext/Repository EnableCascadeSave 可实现保存对象的时候,追朔其OneToOne】、【OneToMany】、【ManyToMany导航属性也一并保存,本文档说明实现的机制防止误用。
DbContext/Repository EnableCascadeSave 可实现保存对象的时候,递归追朔其 OneToOneOneToManyManyToMany 导航属性也一并保存,本文档说明实现的机制防止误用。
1、OneToOne 级联保存
> v3.2.606+ 支持,并且支持级联删除功能
> v3.2.606+ 支持,并且支持[级联删除功能](https://github.com/dotnetcore/FreeSql/wiki/%e5%88%a0%e9%99%a4#ibaserepository-%E7%BA%A7%E8%81%94%E5%88%A0%E9%99%A4)
2、OneToMany 追加保存,不删除表已存在的数据
2、OneToMany 追加或更新子表,不删除表已存在的数据
```csharp
var repo = fsql.GetRepository<Cagetory>();
@@ -43,7 +43,7 @@ repo.DbContextOptions.EnableCascadeSave = true; //需要手工开启
repo.Insert(item);
```
- 不删除表已存在的数据,确认?
- 不删除 Childs 子表已存在的数据,确认?
- 当 Childs 属性为 Empty 时,不做任何操作,确认?
- 保存 Childs 的时候,还会保存 Childs\[0-..\] 的下级集合属性向下18层确认
@@ -51,11 +51,15 @@ repo.Insert(item);
> 保存【类型】表对象的时候,他会向下检索出集合属性【文章】,然后如果【文章】被保存的时候,再继续向下检索出集合属性【评论】。一起做 InsertOrUpdate 操作。
3、ManyToMany 完整保存
3、ManyToMany 完整对比保存中间表,追加外部表
完整保存,对比【多对多】中间表已存在的数据,计算出添加、修改、删除执行。
完整对比保存中间表,对比【多对多】中间表已存在的数据,计算出添加、修改、删除执行。
> ManyToMany 模型下,开启 EnableCascadeSave 或者 SaveMany 都是完整保存
追加外部表,只追加不更新
- 本表 Song
- 外部表 Tag
- 中间表 SongTag
---