LINQ 中的 Join 运算

联接两个数据源就是将一个数据源中的对象与另一个数据源中具有相同公共属性的对象相关联。

当查询所面向的数据源相互之间具有无法直接领会的关系时,Join 就成为一项重要的运算。 在面向对象的编程中,联接可能意味着在未建模对象之间进行关联,例如对单向关系进行反向推理。 下面是单向关系的一个示例:Student 类有一个表示专业的 Department 类型的属性,但 Department 类没有作为 Student 对象集合的属性。 如果有一个 Department 对象列表,并且要查找每个院系的所有学生,则可以使用联接运算进行查找。

LINQ 框架中提供的 join 方法包括 JoinGroupJoin。 这些方法执行同等联接,即根据 2 个数据源的键是否相等来匹配这 2 个数据源的联接。 (为了便于比较,Transact-SQL 支持除 equals 之外的联接运算符,例如 less than 运算符。)用关系数据库术语表达,就是说 Join 实现了内部联接,这种联接只返回那些在另一个数据集中具有匹配项的对象。 GroupJoin 方法在关系数据库术语中没有直接等效项,但实现了内部联接和左外部联接的超集。 左外部联接是指返回第一个(左侧)数据源的每个元素的联接,即使其他数据源中没有关联元素。

下图显示了一个概念性视图,其中包含两个集合以及这两个集合中的包含在内部联接或左外部联接中的元素。

Two overlapping circles showing inner/outer.

方法

方法名 描述 C# 查询表达式语法 详细信息
Join 根据键选择器函数 Join 两个序列并提取值对。 join … in … on … equals … Enumerable.Join

Queryable.Join
GroupJoin 根据键选择器函数 Join 两个序列,并对每个元素的结果匹配项进行分组。 join … in … on … equals … into … Enumerable.GroupJoin

Queryable.GroupJoin

本文中的以下示例使用该领域的常见数据源:

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; }
}

每个 Student 都有年级、主要院系和一系列分数。 Teacher 还有一个 City 属性,用于标识教师的授课校区。 Department 有一个名称,以及对担任院系主任的 Teacher 的引用。

下面的示例使用 join … in … on … equals … 子句基于特定值联接两个序列:

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 … 子句基于特定值联接两个序列,并对每个元素的结果匹配项进行分组:

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}");
    }
}

执行内部联接

在关系数据库术语中,内部联接会生成一个结果集,在该结果集中,第一个集合的每个元素对于第二个集合中的每个匹配元素都会出现一次。 如果第一个集合中的元素没有匹配元素,则它不会出现在结果集中。 由 C# 中的 join 子句调用的 Join 方法可实现内部联接。 以下示例演示如何执行内部联接的四种变体:

  • 基于简单键使两个数据源中的元素相关联的简单内部联接。
  • 基于复合键使两个数据源中的元素相关联的内部联接。 复合键是由多个值组成的键,使你可以基于多个属性使元素相关联。
  • 在其中将连续联接操作相互追加的多联接
  • 使用分组联接实现的内部联接。

单键联接

以下示例将 Teacher 对象与 Deparment 对象进行匹配,后者的 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}");
}

不是院系主任的教师不会出现在最终结果中。

组合键联接

可以使用复合键基于多个属性来比较元素,而不是只基于一个属性使元素相关联。 请为每个集合指定键选择器函数,以返回由要比较的属性组成的匿名类型。 如果对属性进行标记,则它们必须在每个键的匿名类型中具有相同标签。 属性还必须按相同顺序出现。

下面的示例使用 Teacher 对象列表和 Student 对象列表来确定哪些教师同时还是学生。 这两种类型都具有表示每个人的名字和姓氏的属性。 通过每个列表的元素创建联接键的函数会返回一个由属性组成的匿名类型。 联接运算会比较这些组合键是否相等,并从每个列表中返回名字和姓氏都匹配的对象对。

// 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);
}

多联接

可以将任意数量的联接操作相互追加,以执行多联接。 C# 中的每个 join 子句会将指定数据源与上一个联接的结果相关联。

第一个 join 子句根据 Student 对象的 DepartmentIDDepartment 对象的 ID 的匹配情况,将学生和院系进行匹配。 它会返回一个包含 Student 对象和 Department 对象的匿名类型的序列。

第二个 join 子句根据该教师 ID 与院系主任 ID 的匹配情况,将第一个联接返回的匿名类型与 Teacher 对象相关联。 它会返回一个包含学生姓名、院系名称和院系主任姓名的匿名类型序列。 由于此运算是内部联接,因此只返回第一个数据源中在第二个数据源中具有匹配项的对象。

// 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}".""");
}

使用分组联接的内部联接

下面的示例演示如何使用分组联接实现内部联接。 Department 对象列表会基于 Department.IDStudent.DepartmentID 属性的匹配情况,分组联接到 Student 对象列表中。 分组联接会创建中间组的集合,其中每个组都包含 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 子句)执行内部联接来获取的结果集。 以下代码演示了此等效查询:

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}");
}

执行分组联接

分组联接对于生成分层数据结构十分有用。 它将第一个集合中的每个元素与第二个集合中的一组相关元素进行配对。

注意

第一个集合的每个元素都会出现在分组联接的结果集中(无论是否在第二个集合中找到关联元素)。 在未找到任何相关元素的情况下,该元素的相关元素序列为空。 因此,结果选择器有权访问第一个集合的每个元素。 这与非分组联接中的结果选择器不同,后者无法访问第一个集合中在第二个集合中没有匹配项的元素。

警告

Enumerable.GroupJoin 在传统关系数据库术语中没有直接等效项。 但是,此方法实现了内部联接和左外部联接的超集。 这两个操作都可以按照分组联接进行编写。 有关详细信息,请参阅 Entity Framework Core,GroupJoin

本文的第一个示例演示如何执行分组联接。 第二个示例演示如何使用分组联接创建 XML 元素。

分组联接

下面的示例基于与 Student.DepartmentID 属性匹配的 Deoartment.ID,来执行类型 DepartmentStudent 的对象的分组联接。 与非分组联接(会为每个匹配生成元素对)不同,分组联接只为第一个集合的每个元素生成一个结果对象(在此示例中为 Department 对象)。 第二个集合中的对应元素(在此示例中为 Student 对象)会分组到集合中。 最后,结果选择器函数会为每个匹配都创建一种匿名类型,其中包含 Department.NameStudent 对象集合。

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 的分组联接

分组联接非常适合于使用 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);

执行左外部联接

左外部联接是这样定义的:返回第一个集合的每个元素,无论该元素在第二个集合中是否有任何相关元素。 可以使用 LINQ 通过对分组联接的结果调用 DefaultIfEmpty 方法来执行左外部联接。

下面的示例演示如何对分组联接的结果调用 DefaultIfEmpty 方法来执行左外部联接。

若要生成两个集合的左外部联接,第一步是使用分组联接执行内联。 (有关此过程的说明,请参阅执行内联。)在此示例中,Department 对象列表基于与学生的 DepartmentID 匹配的 Department 对象的 ID,内部联接到 Student 对象列表。

第二步是在结果集内包含第一个(左)集合的每个元素,即使该元素在右集合中没有匹配的元素也是如此。 这是通过对分组联接中的每个匹配元素序列调用 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, department) => new { student, subgroup = department.DefaultIfEmpty() })
    .Select(gj => new
    {
        gj.student.FirstName,
        gj.student.LastName,
        Department = gj.subgroup?.FirstOrDefault()?.Name ?? string.Empty
    });

foreach (var v in query)
{
    Console.WriteLine($"{v.FirstName:-15} {v.LastName:-15}: {v.Department}");
}

另请参阅