LINQ 中的 Join 运算
两个数据源的 join 就是将一个数据源中的对象与另一个数据源中具有相同公共属性的对象相关联。
重要
这些示例使用 System.Collections.Generic.IEnumerable<T> 数据源。 基于 System.Linq.IQueryProvider 的数据源使用 System.Linq.IQueryable<T> 数据源和表达式树。 表达式树对允许的 C# 语法有限制。 此外,每个 IQueryProvider
数据源(如 EF Core)可能会施加更多限制。 查看数据源的文档。
当查询所面向的数据源相互之间具有无法直接领会的关系时,联接就成为一项重要的运算。 在面向对象的编程中,联接可能意味着在未建模对象之间进行关联,例如对单向关系进行反向推理。 下面是单向关系的一个示例:Student
类有一个表示专业的 Department
类型的属性,但 Department
类没有作为 Student
对象集合的属性。 如果你具有一个 Department
对象列表,并且要查找每个部门中的所有学生,则可以使用 join 运算完成此项查找。
LINQ 框架中提供的 join 方法包括 Join 和 GroupJoin。 这些方法执行同等联接,即根据 2 个数据源的键是否相等来匹配这 2 个数据源的联接。 (对于比较,Transact-SQL 支持 equals
运算符以外的 join 运算符,例如 less than
运算符)在关系数据库术语中,Join 会实现内部 join,这是一种类型的 join,其中仅返回在其他数据集中具有匹配项的那些对象。 GroupJoin 方法在关系数据库术语中没有直接等效项,但实现了内部联接和左外部联接的超集。 左外部 join 是指返回第一个(左侧)数据源的每个元素的 join,即使其他数据源中没有关联元素。
下图显示了一个概念性视图,其中包含两个集合以及这两个集合中的包含在内部 join 或左外部 join 中的元素。
方法
方法名 | 描述 | C# 查询表达式语法 | 更多信息 |
---|---|---|---|
Join | 根据键选择器函数联接两个序列并提取值对。 | join … in … on … equals … |
Enumerable.Join Queryable.Join |
GroupJoin | 根据键选择器函数联接两个序列,并对每个元素的结果匹配项进行分组。 | join … in … on … equals … into … |
Enumerable.GroupJoin Queryable.GroupJoin |
注意
本文中的以下示例使用此区域的常见数据源。
每个 Student
都有年级、主要院系和一系列分数。 Teacher
还有一个 City
属性,用于标识教师的授课校区。 Department
有一个名称,以及对担任院系主任的 Teacher
的引用。
可以在源存储库中找到示例数据集。
public enum GradeLevel
{
FirstYear = 1,
SecondYear,
ThirdYear,
FourthYear
};
public class Student
{
public required string FirstName { get; init; }
public required string LastName { get; init; }
public required int ID { get; init; }
public required GradeLevel Year { get; init; }
public required List<int> Scores { get; init; }
public required int DepartmentID { get; init; }
}
public class Teacher
{
public required string First { get; init; }
public required string Last { get; init; }
public required int ID { get; init; }
public required string City { get; init; }
}
public class Department
{
public required string Name { get; init; }
public int ID { get; init; }
public required int TeacherID { get; init; }
}
下面的示例使用 join … in … on … equals …
子句基于特定值 join 两个序列:
var query = from student in students
join department in departments on student.DepartmentID equals department.ID
select new { Name = $"{student.FirstName} {student.LastName}", DepartmentName = department.Name };
foreach (var item in query)
{
Console.WriteLine($"{item.Name} - {item.DepartmentName}");
}
可以使用方法语法来表示前面的查询,如以下代码所示:
var query = students.Join(departments,
student => student.DepartmentID, department => department.ID,
(student, department) => new { Name = $"{student.FirstName} {student.LastName}", DepartmentName = department.Name });
foreach (var item in query)
{
Console.WriteLine($"{item.Name} - {item.DepartmentName}");
}
下面的示例使用 join … in … on … equals … into …
子句基于特定值 join 两个序列,并对每个元素的结果匹配项进行分组:
IEnumerable<IEnumerable<Student>> studentGroups = from department in departments
join student in students on department.ID equals student.DepartmentID into studentGroup
select studentGroup;
foreach (IEnumerable<Student> studentGroup in studentGroups)
{
Console.WriteLine("Group");
foreach (Student student in studentGroup)
{
Console.WriteLine($" - {student.FirstName}, {student.LastName}");
}
}
可以使用方法语法来表示前面的查询,如以下示例所示:
// Join department and student based on DepartmentId and grouping result
IEnumerable<IEnumerable<Student>> studentGroups = departments.GroupJoin(students,
department => department.ID, student => student.DepartmentID,
(department, studentGroup) => studentGroup);
foreach (IEnumerable<Student> studentGroup in studentGroups)
{
Console.WriteLine("Group");
foreach (Student student in studentGroup)
{
Console.WriteLine($" - {student.FirstName}, {student.LastName}");
}
}
执行内部联接
在关系数据库术语中,内部 join 会生成一个结果集,在该结果集中,第一个集合的每个元素对于第二个集合中的每个匹配元素都会出现一次。 如果第一个集合中的元素没有匹配元素,则它不会出现在结果集中。 由 C# 中的 join
子句调用的 Join 方法可实现内部 join。 以下示例演示如何执行内部 join 的 4 种变体:
- 基于简单键使两个数据源中的元素相关联的简单内部 join。
- 基于复合键使两个数据源中的元素相关联的内部 join。 复合键是由多个值组成的键,使你可以基于多个属性使元素相关联。
- 在其中将连续 join 操作相互追加的多 join。
- 使用分组 join 实现的内部 join。
单键 join
以下示例将 Teacher
对象与 Department
对象进行匹配,后者的 TeacherId
与该 Teacher
相匹配。 C# 中的 select
子句定义结果对象的外观。 在下面的示例中,结果对象是由院系名称和领导该院系的教师的姓名组成的匿名类型。
var query = from department in departments
join teacher in teachers on department.TeacherID equals teacher.ID
select new
{
DepartmentName = department.Name,
TeacherName = $"{teacher.First} {teacher.Last}"
};
foreach (var departmentAndTeacher in query)
{
Console.WriteLine($"{departmentAndTeacher.DepartmentName} is managed by {departmentAndTeacher.TeacherName}");
}
使用 Join 方法语法实现相同的结果:
var query = teachers
.Join(departments, teacher => teacher.ID, department => department.TeacherID,
(teacher, department) =>
new { DepartmentName = department.Name, TeacherName = $"{teacher.First} {teacher.Last}" });
foreach (var departmentAndTeacher in query)
{
Console.WriteLine($"{departmentAndTeacher.DepartmentName} is managed by {departmentAndTeacher.TeacherName}");
}
不是院系主任的教师不会出现在最终结果中。
复合键 join
可以使用复合键基于多个属性来比较元素,而不是只基于一个属性使元素相关联。 请为每个集合指定键选择器函数,以返回由要比较的属性组成的匿名类型。 如果对属性进行标记,则它们必须在每个键的匿名类型中具有相同标签。 属性还必须按相同顺序出现。
下面的示例使用 Teacher
对象列表和 Student
对象列表来确定哪些教师同时还是学生。 这两种类型都具有表示每个人的名字和姓氏的属性。 通过每个列表的元素创建 join 键的函数会返回由属性组成的匿名类型。 join 运算会比较这些复合键是否相等,并从每个列表返回名字和姓氏都匹配的对象对。
// Join the two data sources based on a composite key consisting of first and last name,
// to determine which employees are also students.
IEnumerable<string> query =
from teacher in teachers
join student in students on new
{
FirstName = teacher.First,
LastName = teacher.Last
} equals new
{
student.FirstName,
student.LastName
}
select teacher.First + " " + teacher.Last;
string result = "The following people are both teachers and students:\r\n";
foreach (string name in query)
{
result += $"{name}\r\n";
}
Console.Write(result);
使用 Join 方法,如以下示例所示:
IEnumerable<string> query = teachers
.Join(students,
teacher => new { FirstName = teacher.First, LastName = teacher.Last },
student => new { student.FirstName, student.LastName },
(teacher, student) => $"{teacher.First} {teacher.Last}"
);
Console.WriteLine("The following people are both teachers and students:");
foreach (string name in query)
{
Console.WriteLine(name);
}
多个 join
可以将任意数量的 join 操作相互追加,以执行多 join。 C# 中的每个 join
子句会将指定数据源与上一个 join 的结果相关联。
第一个 join
子句根据 Student
对象的 DepartmentID
与 Department
对象的 ID
的匹配情况,将学生和院系进行匹配。 它会返回一个包含 Student
对象和 Department
对象的匿名类型的序列。
第二个 join
子句将第一个 join 返回的匿名类型与基于该教师 ID(与部门主管 ID 匹配)的 Teacher
对象相关联。 它会返回一个包含学生姓名、院系名称和院系主任姓名的匿名类型序列。 由于此操作是内部 join,因此只返回第一个数据源中在第二个数据源中具有匹配项的对象。
// The first join matches Department.ID and Student.DepartmentID from the list of students and
// departments, based on a common ID. The second join matches teachers who lead departments
// with the students studying in that department.
var query = from student in students
join department in departments on student.DepartmentID equals department.ID
join teacher in teachers on department.TeacherID equals teacher.ID
select new {
StudentName = $"{student.FirstName} {student.LastName}",
DepartmentName = department.Name,
TeacherName = $"{teacher.First} {teacher.Last}"
};
foreach (var obj in query)
{
Console.WriteLine($"""The student "{obj.StudentName}" studies in the department run by "{obj.TeacherName}".""");
}
使用多个 Join 方法的等效方法对匿名类型使用同一方法:
var query = students
.Join(departments, student => student.DepartmentID, department => department.ID,
(student, department) => new { student, department })
.Join(teachers, commonDepartment => commonDepartment.department.TeacherID, teacher => teacher.ID,
(commonDepartment, teacher) => new
{
StudentName = $"{commonDepartment.student.FirstName} {commonDepartment.student.LastName}",
DepartmentName = commonDepartment.department.Name,
TeacherName = $"{teacher.First} {teacher.Last}"
});
foreach (var obj in query)
{
Console.WriteLine($"""The student "{obj.StudentName}" studies in the department run by "{obj.TeacherName}".""");
}
使用分组的 join 实现的内部 join
下面的示例演示如何使用分组 join 实现内部 join。 Department
对象列表会基于 Department.ID
与 Student.DepartmentID
属性的匹配情况,分组联接到 Student
对象列表中。 分组 join 会创建中间组的集合,其中每个组都包含 Department
对象和匹配 Student
对象的序列。 第二个 from
子句将此序列的序列合并(或平展)为一个较长的序列。 select
子句指定最终序列中元素的类型。 该类型是一个由学生姓名和匹配的院系名称组成的匿名类型。
var query1 =
from department in departments
join student in students on department.ID equals student.DepartmentID into gj
from subStudent in gj
select new
{
DepartmentName = department.Name,
StudentName = $"{subStudent.FirstName} {subStudent.LastName}"
};
Console.WriteLine("Inner join using GroupJoin():");
foreach (var v in query1)
{
Console.WriteLine($"{v.DepartmentName} - {v.StudentName}");
}
可以使用 GroupJoin 方法实现相同的结果,如下所示:
var queryMethod1 = departments
.GroupJoin(students, department => department.ID, student => student.DepartmentID,
(department, gj) => new { department, gj })
.SelectMany(departmentAndStudent => departmentAndStudent.gj,
(departmentAndStudent, subStudent) => new
{
DepartmentName = departmentAndStudent.department.Name,
StudentName = $"{subStudent.FirstName} {subStudent.LastName}"
});
Console.WriteLine("Inner join using GroupJoin():");
foreach (var v in queryMethod1)
{
Console.WriteLine($"{v.DepartmentName} - {v.StudentName}");
}
结果等效于通过使用 join
子句(不使用 into
子句)执行内部 join 来获取的结果集。 以下代码演示了此等效查询:
var query2 = from department in departments
join student in students on department.ID equals student.DepartmentID
select new
{
DepartmentName = department.Name,
StudentName = $"{student.FirstName} {student.LastName}"
};
Console.WriteLine("The equivalent operation using Join():");
foreach (var v in query2)
{
Console.WriteLine($"{v.DepartmentName} - {v.StudentName}");
}
为避免链接,可以使用单个 Join 方法,如此处所示:
var queryMethod2 = departments.Join(students, departments => departments.ID, student => student.DepartmentID,
(department, student) => new
{
DepartmentName = department.Name,
StudentName = $"{student.FirstName} {student.LastName}"
});
Console.WriteLine("The equivalent operation using Join():");
foreach (var v in queryMethod2)
{
Console.WriteLine($"{v.DepartmentName} - {v.StudentName}");
}
执行分组联接
分组 join 对于生成分层数据结构十分有用。 它将第一个集合中的每个元素与第二个集合中的一组相关元素进行配对。
注意
第一个集合的每个元素都会出现在分组 join 的结果集中(无论是否在第二个集合中找到关联元素)。 在未找到任何相关元素的情况下,该元素的相关元素序列为空。 因此,结果选择器有权访问第一个集合的每个元素。 这与非分组 join 中的结果选择器不同,后者无法访问第一个集合中在第二个集合中没有匹配项的元素。
警告
Enumerable.GroupJoin 在传统关系数据库术语中没有直接等效项。 但是,此方法实现了内部联接和左外部联接的超集。 这两个操作都可以按照分组 join 进行编写。 有关详细信息,请参阅 Entity Framework Core,GroupJoin。
本文的第一个示例演示如何执行分组 join。 第二个示例演示如何使用分组 join 创建 XML 元素。
分组 join
下面的示例基于与 Student.DepartmentID
属性匹配的 Department.ID
,来执行类型 Department
和 Student
的对象的分组 join。 与非分组 join(会为每个匹配生成元素对)不同,分组 join 只为第一个集合的每个元素生成一个结果对象(在此示例中为 Department
对象)。 第二个集合中的对应元素(在此示例中为 Student
对象)会分组到集合中。 最后,结果选择器函数会为每个匹配都创建一种匿名类型,其中包含 Department.Name
和 Student
对象集合。
var query = from department in departments
join student in students on department.ID equals student.DepartmentID into studentGroup
select new
{
DepartmentName = department.Name,
Students = studentGroup
};
foreach (var v in query)
{
// Output the department's name.
Console.WriteLine($"{v.DepartmentName}:");
// Output each of the students in that department.
foreach (Student? student in v.Students)
{
Console.WriteLine($" {student.FirstName} {student.LastName}");
}
}
在上面的示例中,query
变量包含的查询创建了一个列表,其中每个元素都是匿名类型,包含院系名称和在该院系学习的学生的集合。
以下代码显示了使用方法语法的等效查询:
var query = departments.GroupJoin(students, department => department.ID, student => student.DepartmentID,
(department, Students) => new { DepartmentName = department.Name, Students });
foreach (var v in query)
{
// Output the department's name.
Console.WriteLine($"{v.DepartmentName}:");
// Output each of the students in that department.
foreach (Student? student in v.Students)
{
Console.WriteLine($" {student.FirstName} {student.LastName}");
}
}
用于创建 XML 的分组 join
分组联接非常适合于使用 LINQ to XML 创建 XML。 下面的示例类似于上面的示例,不过结果选择器函数不会创建匿名类型,而是创建表示联接对象的 XML 元素。
XElement departmentsAndStudents = new("DepartmentEnrollment",
from department in departments
join student in students on department.ID equals student.DepartmentID into studentGroup
select new XElement("Department",
new XAttribute("Name", department.Name),
from student in studentGroup
select new XElement("Student",
new XAttribute("FirstName", student.FirstName),
new XAttribute("LastName", student.LastName)
)
)
);
Console.WriteLine(departmentsAndStudents);
以下代码显示了使用方法语法的等效查询:
XElement departmentsAndStudents = new("DepartmentEnrollment",
departments.GroupJoin(students, department => department.ID, student => student.DepartmentID,
(department, Students) => new XElement("Department",
new XAttribute("Name", department.Name),
from student in Students
select new XElement("Student",
new XAttribute("FirstName", student.FirstName),
new XAttribute("LastName", student.LastName)
)
)
)
);
Console.WriteLine(departmentsAndStudents);
执行左外部联接
左外部 join 是一个 join,其中返回第一个集合的每个元素,无论该元素在第二个集合中是否有任何相关元素。 可以使用 LINQ 通过对分组 join 的结果调用 DefaultIfEmpty 方法来执行左外部 join。
下面的示例演示如何对分组 join 的结果调用 DefaultIfEmpty 方法来执行左外部 join。
若要生成两个集合的左外部 join,第一步是使用分组 join 执行内联 join。 (有关此过程的说明,请参阅执行内联。)在此示例中,Department
对象列表基于与学生的 DepartmentID
匹配的 Department
对象的 ID,内部联接到 Student
对象列表。
第二步是在结果集内包含第一个(左)集合的每个元素,即使该元素在右集合中没有匹配的元素也是如此。 这是通过对分组 join 中的每个匹配元素序列调用 DefaultIfEmpty 来实现的。 此示例中,对每个匹配 Student
对象的序列调用 DefaultIfEmpty。 如果对于任何 Department
对象,匹配的 Student
对象的序列为空,则该方法返回一个包含单个默认值的集合,确保结果集合中显示每个 Department
对象。
注意
引用类型的默认值为 null
;因此,该示例在访问每个 Student
集合的每个元素之前会先检查是否存在空引用。
var query =
from student in students
join department in departments on student.DepartmentID equals department.ID into gj
from subgroup in gj.DefaultIfEmpty()
select new
{
student.FirstName,
student.LastName,
Department = subgroup?.Name ?? string.Empty
};
foreach (var v in query)
{
Console.WriteLine($"{v.FirstName:-15} {v.LastName:-15}: {v.Department}");
}
以下代码显示了使用方法语法的等效查询:
var query = students.GroupJoin(departments, student => student.DepartmentID, department => department.ID,
(student, departmentList) => new { student, subgroup = departmentList.AsQueryable() })
.SelectMany(joinedSet => joinedSet.subgroup.DefaultIfEmpty(), (student, department) => new
{
student.student.FirstName,
student.student.LastName,
Department = department.Name
});
foreach (var v in query)
{
Console.WriteLine($"{v.FirstName:-15} {v.LastName:-15}: {v.Department}");
}