LINQ 中的左外连接

655

如何在 C# LINQ to Objects 中执行左外连接而不使用 join-on-equals-into 子句?是否有办法用 where 子句实现?对于内连接很容易,并且我有一个类似这样的解决方案。

List<JoinPair> innerFinal = (from l in lefts from r in rights where l.Key == r.Key
                             select new JoinPair { LeftId = l.Id, RightId = r.Id})

但是对于左外连接,我需要一个解决办法。我的解决办法类似于这样,但它没有起作用。

List< JoinPair> leftFinal = (from l in lefts from r in rights
                             select new JoinPair { 
                                            LeftId = l.Id, 
                                            RightId = ((l.Key==r.Key) ? r.Id : 0
                                        })

其中JoinPair是一个类:

public class JoinPair { long leftId; long rightId; }

3
你能举个例子说明你想要实现的是什么吗? - jeroenh
2
当然有,但是你应该发布一下你已经拥有的代码示例,这样人们才能给你更好的答案。 - sloth
1
我正在寻找一个“左排除连接”(Left excluding JOIN)(我将其与“OUTER”概念混淆了)。这个答案更接近我想要的内容。 - Nate Anderson
对于 Linq-to-Entities(而不是 Objects),请参见此问题:https://dev59.com/XmIk5IYBdhLWcg3wWc_T - General Grievance
显示剩余2条评论
24个回答

746

“执行左连接”所述:

var q =
    from c in categories
    join pt in products on c.Category equals pt.Category into ps_jointable
    from p in ps_jointable.DefaultIfEmpty()
    select new { Category = c, ProductName = p == null ? "(No products)" : p.ProductName };

7
我也在尝试同样的事情,但是在连接运算符上出现了错误,提示“连接子句中的一个表达式类型不正确”。 - Badhon Jain
3
如果您的类型不同,联接操作将无法执行。因此,很可能您的键具有不同的数据类型。例如,两个键都是整数吗? - Yooakim
2
解决方案是什么,Jain?我也遇到了同样的错误,我的情况中类型也相同。 - Sandeep
3
现在我们可以使用空值合并操作符,例如:select new { Category = c, ProductName = p.ProductName ?? "(没有产品)" };。该操作符可用于检查一个值是否为 null,如果是,则返回默认值。在这个例子中,如果 "ProductName" 为 null,则返回字符串 "(No products)"。 - Wahid Masud
2
在 ps.DefaultIfEmpty() 中的 "p",与 join p in products 中的 "p" 有任何关系吗? - user1169587
显示剩余6条评论

635
如果使用基于数据库的LINQ提供程序,可以将一个更易读的左外连接写成这样:
from c in categories 
from p in products.Where(c == p.Category).DefaultIfEmpty()

如果省略 DefaultIfEmpty(),将会得到内连接。

采用接受的答案:

  from c in categories
    join p in products on c equals p.Category into ps
    from p in ps.DefaultIfEmpty()

这个语法非常令人困惑,当你想要左连接多个表时,它的工作方式并不清楚。


注意
需要注意的是,from alias in Repo.SomeTable.Where(condition).DefaultIfEmpty() 与 outer-apply/left-join-lateral 是相同的,只要你不引入每行值(也称为实际外部应用),任何(良好的)数据库优化器都可以将其转换为左连接。在 Linq-2-Objects 中请勿这样做,因为那里没有数据库优化器......


详细示例

var query2 = (
    from users in Repo.T_User
    from mappings in Repo.T_User_Group
         .Where(mapping => mapping.USRGRP_USR == users.USR_ID)
         .DefaultIfEmpty() // <== makes join left join
    from groups in Repo.T_Group
         .Where(gruppe => gruppe.GRP_ID == mappings.USRGRP_GRP)
         .DefaultIfEmpty() // <== makes join left join

    // where users.USR_Name.Contains(keyword)
    // || mappings.USRGRP_USR.Equals(666)  
    // || mappings.USRGRP_USR == 666 
    // || groups.Name.Contains(keyword)

    select new
    {
         UserId = users.USR_ID
        ,UserName = users.USR_User
        ,UserGroupId = groups.ID
        ,GroupName = groups.Name
    }

);


var xy = (query2).ToList();

在与LINQ to SQL一起使用时,它会很好地转换为以下非常易读的SQL查询:

SELECT 
     users.USR_ID AS UserId 
    ,users.USR_User AS UserName 
    ,groups.ID AS UserGroupId 
    ,groups.Name AS GroupName 
FROM T_User AS users

LEFT JOIN T_User_Group AS mappings
   ON mappings.USRGRP_USR = users.USR_ID

LEFT JOIN T_Group AS groups
    ON groups.GRP_ID == mappings.USRGRP_GRP

我再次强调,如果你在Linq-2-Objects中进行这个操作(而不是Linq-2-SQL),你应该以传统的方式来做(因为LINQ to SQL可以正确地将其转换为连接操作,但在对象上使用这种方法会强制进行全表扫描)。
以下是以实际左连接方式进行操作的传统方法:
    var query2 = (
    from users in Repo.T_Benutzer
    join mappings in Repo.T_Benutzer_Benutzergruppen on mappings.BEBG_BE equals users.BE_ID into tmpMapp
    join groups in Repo.T_Benutzergruppen on groups.ID equals mappings.BEBG_BG into tmpGroups
    from mappings in tmpMapp.DefaultIfEmpty()
    from groups in tmpGroups.DefaultIfEmpty()
    select new
    {
         UserId = users.BE_ID
        ,UserName = users.BE_User
        ,UserGroupId = mappings.BEBG_BG
        ,GroupName = groups.Name
    }

);

现在,以一个更复杂的查询为例,如果你需要进一步解释它是如何工作的,请考虑以下的SQL语句:
DECLARE @BE_ID integer; 
DECLARE @stichtag datetime; 
DECLARE @in_standort uniqueidentifier; 
DECLARE @in_gebaeude uniqueidentifier; 

SET @BE_ID = 123; 
SET @stichtag = CURRENT_TIMESTAMP; 
SET @in_standort = '00000000-0000-0000-0000-000000000000'; 
SET @in_gebaeude = '00000000-0000-0000-0000-000000000000'; 

DECLARE @unixTimestamp bigint; 
DECLARE @bl national character varying(MAX); 
SET @unixTimestamp =  DATEDIFF(SECOND, '1970-01-01T00:00:00.000', CONVERT(DATETIME, @stichtag, 1));

SET @bl = (
    SELECT TOP 1 FC_Value
    FROM T_FMS_Configuration
    WHERE FC_Key = 'basicLink'
);


-- SELECT @unixTimestamp AS unix_ts, @bl AS bl; 


SELECT
    so.SO_Nr AS RPT_SO_Nr 
    ,so.SO_Bezeichnung AS RPT_SO_Bezeichnung
    ,gb.GB_Bezeichnung
    ,gb.GB_GM_Lat
    ,gb.GB_GM_Lng
    ,objTyp.OBJT_Code
    ,@bl + '/Modules/App150/index.html'
        + '?Code=' + COALESCE(objTyp.OBJT_Code, 'BAD')
        + '&UID=' + COALESCE(CAST(gb.GB_UID AS national character varying(MAX)), '')
        + '&Timestamp=' + CONVERT(national character varying(MAX), @unixTimestamp, 126)
    AS RPT_QR 
FROM T_AP_Gebaeude AS gb
LEFT JOIN T_AP_Standort AS so ON gb.GB_SO_UID = so.SO_UID
LEFT JOIN T_OV_Ref_ObjektTyp AS objTyp ON 'GB' = objTyp.OBJT_Code 
LEFT JOIN T_Benutzer AS benutzer ON benutzer.BE_ID = @BE_ID AND benutzer.BE_Status = 1
WHERE gb.GB_Status = 1
AND @stichtag >= gb.GB_DatumVon
AND @stichtag <= gb.GB_DatumBis
AND so.SO_Status = 1
AND @stichtag >= so.SO_DatumVon
AND @stichtag <= so.SO_DatumBis
AND (@in_standort = '00000000-0000-0000-0000-000000000000' OR so.SO_UID = @in_standort)
AND (@in_gebaeude = '00000000-0000-0000-0000-000000000000' OR gb.GB_UID = @in_gebaeude)
AND 
(
       benutzer.BE_ID IS NULL
    OR benutzer.BE_ID < 0
    OR benutzer.BE_usePRT = 0
    OR EXISTS 
    (
            SELECT 1
            FROM T_COR_Objekte AS obj
            INNER JOIN T_COR_ZO_ObjektRechte_Lesen AS objR
                ON objR.ZO_OBJR_OBJ_UID = obj.OBJ_UID
                AND objR.ZO_OBJR_OBJ_OBJT_Code = obj.OBJ_OBJT_Code
            WHERE obj.OBJ_UID = gb.GB_UID
    )
); 

以下是生成的LINQ代码:
(这是DB上下文,dump是LINQpad的一个方法)
int BE_ID = 123; 
System.DateTime stichtag = System.DateTime.Now;
System.Guid in_standort = System.Guid.Empty;
System.Guid in_gebaeude = System.Guid.Empty;

long unixTimestamp = (long)(stichtag.ToUniversalTime() - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalSeconds;

string bl = (
    from c in this.T_FMS_Configuration
    where c.FC_Key == "basicLink"
    select c.FC_Value
).FirstOrDefault();



(
    from gb in this.T_AP_Gebaeude
    join so in this.T_AP_Standort on gb.GB_SO_UID equals so.SO_UID into gb_so
    from so in gb_so.DefaultIfEmpty()
    join objTyp in this.T_OV_Ref_ObjektTyp on "GB" equals objTyp.OBJT_Code into gb_objTyp
    from objTyp in gb_objTyp.DefaultIfEmpty()
    join benutzer in this.T_Benutzer.Where(b => b.BE_ID == BE_ID && b.BE_Status == 1) on 1 equals 1 into gb_benutzer
    from benutzer in gb_benutzer.DefaultIfEmpty()
    where gb.GB_Status == 1 
    && stichtag >= gb.GB_DatumVon 
    && stichtag <= gb.GB_DatumBis 
    && so.SO_Status == 1
    && stichtag >= so.SO_DatumVon 
    && stichtag <= so.SO_DatumBis 
    && (in_standort == System.Guid.Empty|| so.SO_UID == in_standort) 
    && (in_gebaeude == System.Guid.Empty || gb.GB_UID == in_gebaeude) 
    && 
    (
        benutzer == null 
        || benutzer.BE_ID < 0 
        || benutzer.BE_usePRT == false 
        || this.T_COR_Objekte.Any(
                obj => obj.OBJ_UID == gb.GB_UID 
                && this.T_COR_ZO_ObjektRechte_Lesen.Any(objR => objR.ZO_OBJR_OBJ_UID == obj.OBJ_UID && objR.ZO_OBJR_OBJ_OBJT_Code == obj.OBJ_OBJT_Code)
        )
    )


    select new { 
         RPT_SO_Nr = so.SO_Nr 
        ,RPT_SO_Bezeichnung = so.SO_Bezeichnung
        // ,RPT_GB_UID = gb.GB_UID 
        // ,gb.GB_Nr 
        ,gb.GB_Bezeichnung
        // ,adr = gb.GB_Strasse + " " + gb.GB_StrasseNr + ", CH-" + gb.GB_PLZ + " " + gb.GB_Ort 
        ,gb.GB_GM_Lat
        ,gb.GB_GM_Lng
        // ,objTyp.OBJT_UID
        ,objTyp.OBJT_Code 
        
        ,RPT_QR = bl + "/Modules/App150/index.html" 
        + "?Code=" + (objTyp.OBJT_Code ?? "BAD")
        + "&UID=" + (System.Convert.ToString(gb.GB_UID) ?? "" )
        + "&Timestamp=" + unixTimestamp.ToString(System.Globalization.CultureInfo.InvariantCulture)
    }
).Dump();

27
这个回答真的很有帮助。感谢你提供易于理解的语法。 - Chris Marisic
3
想要一个与NHibernate兼容的LINQ查询... :) - mxmissile
35
LINQ to SQL可以正确地将此翻译为联接操作。然而,对于对象,这种方法会强制进行全表扫描。这就是为什么官方文档提供了组连接的解决方案,可以利用哈希索引进行搜索。 - Tamir Daniely
4
我认为显式join的语法比使用whereDefaultIfEmpty更易读、更清晰。 - FindOut_Quran
1
只要你只需要将表a与表b连接起来,这可能是可行的。但是一旦你有更多的需求,它就不再适用了。但即使只有两个表,我认为这也过于冗长了。流行观点似乎也不支持你,因为这个答案从0开始,而顶部答案已经获得了90多个赞。 - Stefan Steiger
显示剩余23条评论

185

使用 lambda 表达式

db.Categories    
  .GroupJoin(db.Products,
      Category => Category.CategoryId,
      Product => Product.CategoryId,
      (x, y) => new { Category = x, Products = y })
  .SelectMany(
      xy => xy.Products.DefaultIfEmpty(),
      (x, y) => new { Category = x.Category, Product = y })
  .Select(s => new
  {
      CategoryName = s.Category.Name,     
      ProductName = s.Product.Name   
  });

11
Join 和 GroupJoin 都不太支持左连接。使用 GroupJoin 的技巧是可以有空分组,然后将这些空分组转换为空值。DefaultIfEmpty 就是这样做的,意思是 Enumerable.Empty<Product>.DefaultIfEmpty() 会返回一个带有单个 default(Product) 值的 IEnumerable。 - Tamir Daniely
104
这么麻烦就为了执行左连接? - FindOut_Quran
12
谢谢!Lambda表达式的例子不多,这个对我很有用。 - Johan Henkens
6
不需要最后的Select(),可以重构SelectMany()中的匿名对象以获得相同的输出。另一个想法是测试y是否为null,以模拟更接近LEFT JOIN的等效性。 - Denny Jacob
2
正如 @DennyJacob 所暗示的,您需要执行 s.Product?.Name 或以其他方式处理 null。令人困惑的是,这个页面上有多少答案会在左侧列表包含右侧没有的项时抛出异常,即使处理这种情况恰好是左连接的整个目的。 - MarredCheese
显示剩余5条评论

61
现在作为一个扩展方法:
public static class LinqExt
{
    public static IEnumerable<TResult> LeftOuterJoin<TLeft, TRight, TKey, TResult>(this IEnumerable<TLeft> left, IEnumerable<TRight> right, Func<TLeft, TKey> leftKey, Func<TRight, TKey> rightKey,
        Func<TLeft, TRight, TResult> result)
    {
        return left.GroupJoin(right, leftKey, rightKey, (l, r) => new { l, r })
             .SelectMany(
                 o => o.r.DefaultIfEmpty(),
                 (l, r) => new { lft= l.l, rght = r })
             .Select(o => result.Invoke(o.lft, o.rght));
    }
}

使用方法与正常使用join一样:

var contents = list.LeftOuterJoin(list2, 
             l => l.country, 
             r => r.name,
            (l, r) => new { count = l.Count(), l.country, l.reason, r.people })

希望这能为您节省一些时间。

2
这很好,但在你的例子中,如果list包含list2没有的键,那么r.people会抛出异常,因为r将是null。 应该是r?.people吗?否则,它只是一个还会抛出异常的内部连接。或者,我认为您可以向LeftOuterJoin()添加“默认右元素”参数,并将其传递到DefaultIfEmpty()中。 - MarredCheese
4
这句话应该翻译为“这不应该是针对IQueryable吗?”。 - NetMage

52

看一下这个示例。 这个查询应该可以工作:

var leftFinal = from left in lefts
                join right in rights on left equals right.Left into leftRights
                from leftRight in leftRights.DefaultIfEmpty()
                select new { LeftId = left.Id, RightId = left.Key==leftRight.Key ? leftRight.Id : 0 };

3
在使用了JOIN INTO之后,在SELECT子句中能否访问r - Farhad Alizadeh Noori
@FarhadAlizadehNoori 是的,它可以。 - Po-ta-toe
作者可能意味着在第二个from子句中重复使用r,即from r in lrs.DefaultIfEmpty()。否则,这个查询就没有多大意义,而且由于选择器中的r上下文不明,可能甚至无法编译。 - Saeb Amini
@Devart,当我看到你的查询时,让我想起了影片《抢先一步》(Clockwise)中的约翰·克里斯(John Cleese),哈哈。 - Matas Vaitkevicius
2
从左到右,从右到左,在右边的左边,在左边的右边... 哦天啊... 在LINQ中使用LEFT OUTER JOIN的语法真的不清楚,但这些名称确实使它更加不清楚。 - Mike Gledhill
@Po-ta-toe 不行,因为 into 会创建新的闭包。 - Soner from The Ottoman Empire

20

使用扩展方法实现左外连接可能如下所示:

public static IEnumerable<Result> LeftJoin<TOuter, TInner, TKey, Result>(
  this IEnumerable<TOuter> outer, IEnumerable<TInner> inner
  , Func<TOuter, TKey> outerKeySelector, Func<TInner, TKey> innerKeySelector
  , Func<TOuter, TInner, Result> resultSelector, IEqualityComparer<TKey> comparer)
  {
    if (outer == null)
      throw new ArgumentException("outer");

    if (inner == null)
      throw new ArgumentException("inner");

    if (outerKeySelector == null)
      throw new ArgumentException("outerKeySelector");

    if (innerKeySelector == null)
      throw new ArgumentException("innerKeySelector");

    if (resultSelector == null)
      throw new ArgumentException("resultSelector");

    return LeftJoinImpl(outer, inner, outerKeySelector, innerKeySelector, resultSelector, comparer ?? EqualityComparer<TKey>.Default);
  }

  static IEnumerable<Result> LeftJoinImpl<TOuter, TInner, TKey, Result>(
      IEnumerable<TOuter> outer, IEnumerable<TInner> inner
      , Func<TOuter, TKey> outerKeySelector, Func<TInner, TKey> innerKeySelector
      , Func<TOuter, TInner, Result> resultSelector, IEqualityComparer<TKey> comparer)
  {
    var innerLookup = inner.ToLookup(innerKeySelector, comparer);

    foreach (var outerElment in outer)
    {
      var outerKey = outerKeySelector(outerElment);
      var innerElements = innerLookup[outerKey];

      if (innerElements.Any())
        foreach (var innerElement in innerElements)
          yield return resultSelector(outerElment, innerElement);
      else
        yield return resultSelector(outerElment, default(TInner));
     }
   }
结果选择器需要处理空元素。例如:
   static void Main(string[] args)
   {
     var inner = new[] { Tuple.Create(1, "1"), Tuple.Create(2, "2"), Tuple.Create(3, "3") };
     var outer = new[] { Tuple.Create(1, "11"), Tuple.Create(2, "22") };

     var res = outer.LeftJoin(inner, item => item.Item1, item => item.Item1, (it1, it2) =>
     new { Key = it1.Item1, V1 = it1.Item2, V2 = it2 != null ? it2.Item2 : default(string) });

     foreach (var item in res)
       Console.WriteLine(string.Format("{0}, {1}, {2}", item.Key, item.V1, item.V2));
   }

5
然而,这只是针对LINQ to objects的一个选项,不能将查询转换为任何查询提供程序,而这正是此操作最常见的用例。 - Servy
16
但问题是“如何在C# LINQ对_对象_执行左外连接…” - Bertrand
1
LeftJoin 方法中的最后一个参数“comparer”应该是可选参数,等于 null。我猜。 - alanextar

13

看看这个例子

class Person
{
    public int ID { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string Phone { get; set; }
}

class Pet
{
    public string Name { get; set; }
    public Person Owner { get; set; }
}

public static void LeftOuterJoinExample()
{
    Person magnus = new Person {ID = 1, FirstName = "Magnus", LastName = "Hedlund"};
    Person terry = new Person {ID = 2, FirstName = "Terry", LastName = "Adams"};
    Person charlotte = new Person {ID = 3, FirstName = "Charlotte", LastName = "Weiss"};
    Person arlene = new Person {ID = 4, FirstName = "Arlene", LastName = "Huff"};

    Pet barley = new Pet {Name = "Barley", Owner = terry};
    Pet boots = new Pet {Name = "Boots", Owner = terry};
    Pet whiskers = new Pet {Name = "Whiskers", Owner = charlotte};
    Pet bluemoon = new Pet {Name = "Blue Moon", Owner = terry};
    Pet daisy = new Pet {Name = "Daisy", Owner = magnus};

    // Create two lists.
    List<Person> people = new List<Person> {magnus, terry, charlotte, arlene};
    List<Pet> pets = new List<Pet> {barley, boots, whiskers, bluemoon, daisy};

    var query = from person in people
        where person.ID == 4
        join pet in pets on person equals pet.Owner  into personpets
        from petOrNull in personpets.DefaultIfEmpty()
        select new { Person=person, Pet = petOrNull}; 



    foreach (var v in query )
    {
        Console.WriteLine("{0,-15}{1}", v.Person.FirstName + ":", (v.Pet == null ? "Does not Exist" : v.Pet.Name));
    }
}

// This code produces the following output:
//
// Magnus:        Daisy
// Terry:         Barley
// Terry:         Boots
// Terry:         Blue Moon
// Charlotte:     Whiskers
// Arlene:

现在您可以从左侧包含元素,即使该元素在右侧没有匹配项,在我们的情况下,我们检索了,即使他在右侧没有匹配项。

这里是参考链接

如何执行左外部连接 (C# 编程指南)


Arlene:不存在。 - user1169587

12

这是一般形式(如其他答案已经提供的)

var c =
    from a in alpha
    join b in beta on b.field1 equals a.field1 into b_temp
    from b_value in b_temp.DefaultIfEmpty()
    select new { Alpha = a, Beta = b_value };

不过,以下是我希望能够澄清这个问题的解释!

join b in beta on b.field1 equals a.field1 into b_temp

基本上创建了一个名为b_temp的单独结果集,有效地包括右侧(即'b'中条目)的空“行”。

接下来是下一行:

from b_value in b_temp.DefaultIfEmpty()

迭代该结果集,将右侧的“行”设置为默认的空值,并将右侧行连接的结果设置为“b_value”的值(即如果存在匹配记录,则在右侧上的值,否则为“null”)。

现在,如果右侧是单独的LINQ查询的结果,则它将由匿名类型组成,只能是“something”或“null”。但如果它是一个可枚举对象(例如List - 其中MyObjectB是具有2个字段的类),则可以明确指定其属性使用的默认“null”值:

var c =
    from a in alpha
    join b in beta on b.field1 equals a.field1 into b_temp
    from b_value in b_temp.DefaultIfEmpty( new MyObjectB { Field1 = String.Empty, Field2 = (DateTime?) null })
    select new { Alpha = a, Beta_field1 = b_value.Field1, Beta_field2 = b_value.Field2 };
这确保了'b'本身不为null(但它的属性可以是null,使用您指定的默认空值),这使您能够检查b_value的属性,而不会因b_value为空引用而导致空引用异常。请注意,对于可空的DateTime,必须将(DateTime?)即“可空DateTime”类型指定为规范中的空值的“Type”,以便于“DefaultIfEmpty”一起使用(这也适用于不是本机可空的类型,如double,float)。
您可以通过简单地链接上述语法来执行多个左外连接。

3
b_value 来自哪里? - Jack Fraser

10
如果您需要连接超过2个表,请参考以下示例:
from d in context.dc_tpatient_bookingd
join bookingm in context.dc_tpatient_bookingm 
     on d.bookingid equals bookingm.bookingid into bookingmGroup
from m in bookingmGroup.DefaultIfEmpty()
join patient in dc_tpatient
     on m.prid equals patient.prid into patientGroup
from p in patientGroup.DefaultIfEmpty()

Ref: https://dev59.com/1mQn5IYBdhLWcg3wCzek#17142392


8

以下是使用方法语法比较容易理解的版本:

IEnumerable<JoinPair> outerLeft =
    lefts.SelectMany(l => 
        rights.Where(r => l.Key == r.Key)
              .DefaultIfEmpty(new Item())
              .Select(r => new JoinPair { LeftId = l.Id, RightId = r.Id }));

3
有趣的是,避免使用名称中包含“join”的LINQ函数更加简单。 - MarredCheese

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接