LINQ: unión externa completa

Resuelto ninjaPixel asked hace 13 años • 18 respuestas

Tengo una lista de identificación de personas y su nombre, y una lista de identificación de personas y su apellido. Algunas personas no tienen nombre y otras no tienen apellido; Me gustaría hacer una unión externa completa en las dos listas.

Entonces las siguientes listas:

ID  FirstName
--  ---------
 1  John
 2  Sue

ID  LastName
--  --------
 1  Doe
 3  Smith

Debe producir:

ID  FirstName  LastName
--  ---------  --------
 1  John       Doe
 2  Sue
 3             Smith

He encontrado bastantes soluciones para 'Uniones externas de LINQ' que parecen bastante similares, pero en realidad parecen ser uniones externas izquierdas.

Mis intentos hasta ahora son algo como esto:

private void OuterJoinTest()
{
    List<FirstName> firstNames = new List<FirstName>();
    firstNames.Add(new FirstName { ID = 1, Name = "John" });
    firstNames.Add(new FirstName { ID = 2, Name = "Sue" });

    List<LastName> lastNames = new List<LastName>();
    lastNames.Add(new LastName { ID = 1, Name = "Doe" });
    lastNames.Add(new LastName { ID = 3, Name = "Smith" });

    var outerJoin = from first in firstNames
        join last in lastNames
        on first.ID equals last.ID
        into temp
        from last in temp.DefaultIfEmpty()
        select new
        {
            id = first != null ? first.ID : last.ID,
            firstname = first != null ? first.Name : string.Empty,
            surname = last != null ? last.Name : string.Empty
        };
    }
}

public class FirstName
{
    public int ID;
    
    public string Name;
}
    
public class LastName
{
    public int ID;
    
    public string Name;
}

Pero esto regresa:

ID  FirstName  LastName
--  ---------  --------
 1  John       Doe
 2  Sue

¿Qué estoy haciendo mal?

ninjaPixel avatar Mar 31 '11 00:03 ninjaPixel
Aceptado

Actualización 1: proporcionar un método de extensión verdaderamente generalizado FullOuterJoin
Actualización 2: aceptar opcionalmente una personalización IEqualityComparerpara el tipo de clave
Actualización 3 : esta implementación se ha convertido recientemente en parte deMoreLinq - ¡Gracias chicos!

Editar agregado FullOuterGroupJoin( ideone ). Reutilicé la GetOuter<>implementación, lo que hizo que esto tuviera un rendimiento una fracción menor de lo que podría ser, pero ahora mi objetivo es un código de 'alto nivel', no optimizado de última generación.

Véalo en vivo en http://ideone.com/O36nWc

static void Main(string[] args)
{
    var ax = new[] { 
        new { id = 1, name = "John" },
        new { id = 2, name = "Sue" } };
    var bx = new[] { 
        new { id = 1, surname = "Doe" },
        new { id = 3, surname = "Smith" } };

    ax.FullOuterJoin(bx, a => a.id, b => b.id, (a, b, id) => new {a, b})
        .ToList().ForEach(Console.WriteLine);
}

Imprime la salida:

{ a = { id = 1, name = John }, b = { id = 1, surname = Doe } }
{ a = { id = 2, name = Sue }, b =  }
{ a = , b = { id = 3, surname = Smith } }

También puede proporcionar los valores predeterminados: http://ideone.com/kG4kqO

    ax.FullOuterJoin(
            bx, a => a.id, b => b.id, 
            (a, b, id) => new { a.name, b.surname },
            new { id = -1, name    = "(no firstname)" },
            new { id = -2, surname = "(no surname)" }
        )

Impresión:

{ name = John, surname = Doe }
{ name = Sue, surname = (no surname) }
{ name = (no firstname), surname = Smith }

Explicación de términos utilizados:

Unirse es un término tomado del diseño de bases de datos relacionales:

  • Una combinación repetirá elementos atantas veces como elementos tenga b la clave correspondiente (es decir, nada si bestuviera vacío). La jerga de las bases de datos lo llamainner (equi)join .
  • Una unión externa incluye elementos apara los cuales no existe ningún elemento correspondiente en b. (es decir: resultados pares si bestuvieran vacíos). A esto se le suele denominarleft join .
  • Una combinación externa completa incluye registros de a yb si no existe ningún elemento correspondiente en el otro. (es decir, incluso resultados si aestuvieran vacíos)

Algo que no suele verse en RDBMS es unirse a un grupo [1] :

  • Una unión de grupo hace lo mismo que se describe anteriormente, pero en lugar de repetir elementos apara múltiples correspondientes b, agrupa los registros con las claves correspondientes. Esto suele ser más conveniente cuando desea enumerar registros 'unidos', basándose en una clave común.

Consulte también GroupJoin , que también contiene algunas explicaciones generales.


[1] (Creo que Oracle y MSSQL tienen extensiones propietarias para esto)

código completo

Una clase de extensión generalizada para esto

internal static class MyExtensions
{
    internal static IEnumerable<TResult> FullOuterGroupJoin<TA, TB, TKey, TResult>(
        this IEnumerable<TA> a,
        IEnumerable<TB> b,
        Func<TA, TKey> selectKeyA, 
        Func<TB, TKey> selectKeyB,
        Func<IEnumerable<TA>, IEnumerable<TB>, TKey, TResult> projection,
        IEqualityComparer<TKey> cmp = null)
    {
        cmp = cmp?? EqualityComparer<TKey>.Default;
        var alookup = a.ToLookup(selectKeyA, cmp);
        var blookup = b.ToLookup(selectKeyB, cmp);

        var keys = new HashSet<TKey>(alookup.Select(p => p.Key), cmp);
        keys.UnionWith(blookup.Select(p => p.Key));

        var join = from key in keys
                   let xa = alookup[key]
                   let xb = blookup[key]
                   select projection(xa, xb, key);

        return join;
    }

    internal static IEnumerable<TResult> FullOuterJoin<TA, TB, TKey, TResult>(
        this IEnumerable<TA> a,
        IEnumerable<TB> b,
        Func<TA, TKey> selectKeyA, 
        Func<TB, TKey> selectKeyB,
        Func<TA, TB, TKey, TResult> projection,
        TA defaultA = default(TA), 
        TB defaultB = default(TB),
        IEqualityComparer<TKey> cmp = null)
    {
        cmp = cmp?? EqualityComparer<TKey>.Default;
        var alookup = a.ToLookup(selectKeyA, cmp);
        var blookup = b.ToLookup(selectKeyB, cmp);

        var keys = new HashSet<TKey>(alookup.Select(p => p.Key), cmp);
        keys.UnionWith(blookup.Select(p => p.Key));

        var join = from key in keys
                   from xa in alookup[key].DefaultIfEmpty(defaultA)
                   from xb in blookup[key].DefaultIfEmpty(defaultB)
                   select projection(xa, xb, key);

        return join;
    }
}
sehe avatar Nov 21 '2012 23:11 sehe

No sé si esto abarca todos los casos, lógicamente parece correcto. La idea es tomar una combinación externa izquierda y una combinación externa derecha y luego tomar la unión de los resultados.

var firstNames = new[]
{
    new { ID = 1, Name = "John" },
    new { ID = 2, Name = "Sue" },
};
var lastNames = new[]
{
    new { ID = 1, Name = "Doe" },
    new { ID = 3, Name = "Smith" },
};
var leftOuterJoin =
    from first in firstNames
    join last in lastNames on first.ID equals last.ID into temp
    from last in temp.DefaultIfEmpty()
    select new
    {
        first.ID,
        FirstName = first.Name,
        LastName = last?.Name,
    };
var rightOuterJoin =
    from last in lastNames
    join first in firstNames on last.ID equals first.ID into temp
    from first in temp.DefaultIfEmpty()
    select new
    {
        last.ID,
        FirstName = first?.Name,
        LastName = last.Name,
    };
var fullOuterJoin = leftOuterJoin.Union(rightOuterJoin);

Esto funciona tal como está escrito ya que está en LINQ to Objects. Si es LINQ to SQL u otro, es posible que el procesador de consultas no admita una navegación segura u otras operaciones. Tendrías que usar el operador condicional para obtener los valores de forma condicional.

es decir,

var leftOuterJoin =
    from first in firstNames
    join last in lastNames on first.ID equals last.ID into temp
    from last in temp.DefaultIfEmpty()
    select new
    {
        first.ID,
        FirstName = first.Name,
        LastName = last != null ? last.Name : default,
    };
Jeff Mercado avatar Mar 30 '2011 19:03 Jeff Mercado

Creo que hay problemas con la mayoría de estos, incluida la respuesta aceptada, porque no funcionan bien con Linq sobre IQueryable, ya sea porque realizan demasiados viajes de ida y vuelta al servidor y demasiadas devoluciones de datos, o porque realizan demasiadas ejecuciones del cliente.

Para IEnumerable no me gusta la respuesta de Sehe o similar porque tiene un uso excesivo de memoria (una simple prueba de dos listas de 10000000 hizo que Linqpad se quedara sin memoria en mi máquina de 32 GB).

Además, la mayoría de los demás en realidad no implementan una unión externa completa adecuada porque están usando una unión con una unión derecha en lugar de Concat con una semiunión derecha, lo que no solo elimina las filas de unión interna duplicadas del resultado, sino que cualquier duplicado adecuado que existiera originalmente en los datos izquierdo o derecho.

Entonces, aquí están mis extensiones que manejan todos estos problemas, generan SQL e implementan la unión en LINQ to SQL directamente, se ejecutan en el servidor y son más rápidas y con menos memoria que otras en Enumerables:

public static class Ext {
    public static IEnumerable<TResult> LeftOuterJoin<TLeft, TRight, TKey, TResult>(
        this IEnumerable<TLeft> leftItems,
        IEnumerable<TRight> rightItems,
        Func<TLeft, TKey> leftKeySelector,
        Func<TRight, TKey> rightKeySelector,
        Func<TLeft, TRight, TResult> resultSelector) {

        return from left in leftItems
               join right in rightItems on leftKeySelector(left) equals rightKeySelector(right) into temp
               from right in temp.DefaultIfEmpty()
               select resultSelector(left, right);
    }

    public static IEnumerable<TResult> RightOuterJoin<TLeft, TRight, TKey, TResult>(
        this IEnumerable<TLeft> leftItems,
        IEnumerable<TRight> rightItems,
        Func<TLeft, TKey> leftKeySelector,
        Func<TRight, TKey> rightKeySelector,
        Func<TLeft, TRight, TResult> resultSelector) {

        return from right in rightItems
               join left in leftItems on rightKeySelector(right) equals leftKeySelector(left) into temp
               from left in temp.DefaultIfEmpty()
               select resultSelector(left, right);
    }

    public static IEnumerable<TResult> FullOuterJoinDistinct<TLeft, TRight, TKey, TResult>(
        this IEnumerable<TLeft> leftItems,
        IEnumerable<TRight> rightItems,
        Func<TLeft, TKey> leftKeySelector,
        Func<TRight, TKey> rightKeySelector,
        Func<TLeft, TRight, TResult> resultSelector) {

        return leftItems.LeftOuterJoin(rightItems, leftKeySelector, rightKeySelector, resultSelector).Union(leftItems.RightOuterJoin(rightItems, leftKeySelector, rightKeySelector, resultSelector));
    }

    public static IEnumerable<TResult> RightAntiSemiJoin<TLeft, TRight, TKey, TResult>(
        this IEnumerable<TLeft> leftItems,
        IEnumerable<TRight> rightItems,
        Func<TLeft, TKey> leftKeySelector,
        Func<TRight, TKey> rightKeySelector,
        Func<TLeft, TRight, TResult> resultSelector) {

        var hashLK = new HashSet<TKey>(from l in leftItems select leftKeySelector(l));
        return rightItems.Where(r => !hashLK.Contains(rightKeySelector(r))).Select(r => resultSelector(default(TLeft),r));
    }

    public static IEnumerable<TResult> FullOuterJoin<TLeft, TRight, TKey, TResult>(
        this IEnumerable<TLeft> leftItems,
        IEnumerable<TRight> rightItems,
        Func<TLeft, TKey> leftKeySelector,
        Func<TRight, TKey> rightKeySelector,
        Func<TLeft, TRight, TResult> resultSelector)  where TLeft : class {

        return leftItems.LeftOuterJoin(rightItems, leftKeySelector, rightKeySelector, resultSelector).Concat(leftItems.RightAntiSemiJoin(rightItems, leftKeySelector, rightKeySelector, resultSelector));
    }

    private static Expression<Func<TP, TC, TResult>> CastSMBody<TP, TC, TResult>(LambdaExpression ex, TP unusedP, TC unusedC, TResult unusedRes) => (Expression<Func<TP, TC, TResult>>)ex;

    public static IQueryable<TResult> LeftOuterJoin<TLeft, TRight, TKey, TResult>(
        this IQueryable<TLeft> leftItems,
        IQueryable<TRight> rightItems,
        Expression<Func<TLeft, TKey>> leftKeySelector,
        Expression<Func<TRight, TKey>> rightKeySelector,
        Expression<Func<TLeft, TRight, TResult>> resultSelector) {

        var sampleAnonLR = new { left = default(TLeft), rightg = default(IEnumerable<TRight>) };
        var parmP = Expression.Parameter(sampleAnonLR.GetType(), "p");
        var parmC = Expression.Parameter(typeof(TRight), "c");
        var argLeft = Expression.PropertyOrField(parmP, "left");
        var newleftrs = CastSMBody(Expression.Lambda(Expression.Invoke(resultSelector, argLeft, parmC), parmP, parmC), sampleAnonLR, default(TRight), default(TResult));

        return leftItems.AsQueryable().GroupJoin(rightItems, leftKeySelector, rightKeySelector, (left, rightg) => new { left, rightg }).SelectMany(r => r.rightg.DefaultIfEmpty(), newleftrs);
    }

    public static IQueryable<TResult> RightOuterJoin<TLeft, TRight, TKey, TResult>(
        this IQueryable<TLeft> leftItems,
        IQueryable<TRight> rightItems,
        Expression<Func<TLeft, TKey>> leftKeySelector,
        Expression<Func<TRight, TKey>> rightKeySelector,
        Expression<Func<TLeft, TRight, TResult>> resultSelector) {

        var sampleAnonLR = new { leftg = default(IEnumerable<TLeft>), right = default(TRight) };
        var parmP = Expression.Parameter(sampleAnonLR.GetType(), "p");
        var parmC = Expression.Parameter(typeof(TLeft), "c");
        var argRight = Expression.PropertyOrField(parmP, "right");
        var newrightrs = CastSMBody(Expression.Lambda(Expression.Invoke(resultSelector, parmC, argRight), parmP, parmC), sampleAnonLR, default(TLeft), default(TResult));

        return rightItems.GroupJoin(leftItems, rightKeySelector, leftKeySelector, (right, leftg) => new { leftg, right }).SelectMany(l => l.leftg.DefaultIfEmpty(), newrightrs);
    }

    public static IQueryable<TResult> FullOuterJoinDistinct<TLeft, TRight, TKey, TResult>(
        this IQueryable<TLeft> leftItems,
        IQueryable<TRight> rightItems,
        Expression<Func<TLeft, TKey>> leftKeySelector,
        Expression<Func<TRight, TKey>> rightKeySelector,
        Expression<Func<TLeft, TRight, TResult>> resultSelector) {

        return leftItems.LeftOuterJoin(rightItems, leftKeySelector, rightKeySelector, resultSelector).Union(leftItems.RightOuterJoin(rightItems, leftKeySelector, rightKeySelector, resultSelector));
    }

    private static Expression<Func<TP, TResult>> CastSBody<TP, TResult>(LambdaExpression ex, TP unusedP, TResult unusedRes) => (Expression<Func<TP, TResult>>)ex;

    public static IQueryable<TResult> RightAntiSemiJoin<TLeft, TRight, TKey, TResult>(
        this IQueryable<TLeft> leftItems,
        IQueryable<TRight> rightItems,
        Expression<Func<TLeft, TKey>> leftKeySelector,
        Expression<Func<TRight, TKey>> rightKeySelector,
        Expression<Func<TLeft, TRight, TResult>> resultSelector) {

        var sampleAnonLgR = new { leftg = default(IEnumerable<TLeft>), right = default(TRight) };
        var parmLgR = Expression.Parameter(sampleAnonLgR.GetType(), "lgr");
        var argLeft = Expression.Constant(default(TLeft), typeof(TLeft));
        var argRight = Expression.PropertyOrField(parmLgR, "right");
        var newrightrs = CastSBody(Expression.Lambda(Expression.Invoke(resultSelector, argLeft, argRight), parmLgR), sampleAnonLgR, default(TResult));

        return rightItems.GroupJoin(leftItems, rightKeySelector, leftKeySelector, (right, leftg) => new { leftg, right }).Where(lgr => !lgr.leftg.Any()).Select(newrightrs);
    }

    public static IQueryable<TResult> FullOuterJoin<TLeft, TRight, TKey, TResult>(
        this IQueryable<TLeft> leftItems,
        IQueryable<TRight> rightItems,
        Expression<Func<TLeft, TKey>> leftKeySelector,
        Expression<Func<TRight, TKey>> rightKeySelector,
        Expression<Func<TLeft, TRight, TResult>> resultSelector) {

        return leftItems.LeftOuterJoin(rightItems, leftKeySelector, rightKeySelector, resultSelector).Concat(leftItems.RightAntiSemiJoin(rightItems, leftKeySelector, rightKeySelector, resultSelector));
    }
}

La diferencia entre un Right Anti-Semi-Join es mayormente discutible con Linq to Objects o en el código fuente, pero hace una diferencia en el lado del servidor (SQL) en la respuesta final, eliminando un archivo JOIN.

La codificación manual Expressionpara manejar la fusión de un Expression<Func<>>en una lambda podría mejorarse con LinqKit, pero sería bueno si el lenguaje/compilador hubiera agregado algo de ayuda para eso. Las funciones FullOuterJoinDistincty RightOuterJoinse incluyen para que estén completas, pero aún no las he vuelto a implementar FullOuterGroupJoin.

Escribí otra versión de una unión externa completa para IEnumerablelos casos en los que la clave se puede ordenar, lo cual es aproximadamente un 50 % más rápido que combinar la unión externa izquierda con la anti semi unión derecha, al menos en colecciones pequeñas. Pasa por cada colección después de clasificarla solo una vez.

También agregué otra respuesta para una versión que funciona con EF reemplazando Invokecon una expansión personalizada.

NetMage avatar Apr 27 '2017 22:04 NetMage

Supongo que el enfoque de @sehe es más fuerte, pero hasta que lo entienda mejor, me encuentro saltando de la extensión de @MichaelSander. Lo modifiqué para que coincida con la sintaxis y el tipo de retorno del método incorporado Enumerable.Join() que se describe aquí . Agregué el sufijo "distinto" con respecto al comentario de @cadrell0 en la solución de @JeffMercado.

public static class MyExtensions {

    public static IEnumerable<TResult> FullJoinDistinct<TLeft, TRight, TKey, TResult> (
        this IEnumerable<TLeft> leftItems, 
        IEnumerable<TRight> rightItems, 
        Func<TLeft, TKey> leftKeySelector, 
        Func<TRight, TKey> rightKeySelector,
        Func<TLeft, TRight, TResult> resultSelector
    ) {

        var leftJoin = 
            from left in leftItems
            join right in rightItems 
              on leftKeySelector(left) equals rightKeySelector(right) into temp
            from right in temp.DefaultIfEmpty()
            select resultSelector(left, right);

        var rightJoin = 
            from right in rightItems
            join left in leftItems 
              on rightKeySelector(right) equals leftKeySelector(left) into temp
            from left in temp.DefaultIfEmpty()
            select resultSelector(left, right);

        return leftJoin.Union(rightJoin);
    }

}

En el ejemplo, lo usarías así:

var test = 
    firstNames
    .FullJoinDistinct(
        lastNames,
        f=> f.ID,
        j=> j.ID,
        (f,j)=> new {
            ID = f == null ? j.ID : f.ID, 
            leftName = f == null ? null : f.Name,
            rightName = j == null ? null : j.Name
        }
    );

En el futuro, a medida que aprenda más, tengo la sensación de que migraré a la lógica de @sehe dada su popularidad. Pero incluso entonces tendré que tener cuidado, porque creo que es importante tener al menos una sobrecarga que coincida con la sintaxis del método ".Join()" existente, si es posible, por dos razones:

  1. La coherencia en los métodos ayuda a ahorrar tiempo, evitar errores y evitar comportamientos no deseados.
  2. Si alguna vez hay un método ".FullJoin()" listo para usar en el futuro, me imagino que, si puede, intentará mantener la sintaxis del método ".Join()" actualmente existente. Si es así, si desea migrar a él, simplemente puede cambiar el nombre de sus funciones sin cambiar los parámetros ni preocuparse de que los diferentes tipos de retorno rompan su código.

Todavía soy nuevo en el mundo de los genéricos, las extensiones, las declaraciones Func y otras características, por lo que los comentarios son bienvenidos.

EDITAR: No me tomó mucho tiempo darme cuenta de que había un problema con mi código. Estaba haciendo un .Dump() en LINQPad y mirando el tipo de retorno. Era simplemente IEnumerable, así que intenté hacer coincidirlo. Pero cuando hice .Where() o .Select() en mi extensión, recibí un error: "'System Collections.IEnumerable' no contiene una definición para 'Select' y ...". Entonces, al final pude hacer coincidir la sintaxis de entrada de .Join(), pero no el comportamiento de devolución.

EDITAR: Se agregó "TResult" al tipo de retorno de la función. Se me pasó por alto eso al leer el artículo de Microsoft y, por supuesto, tiene sentido. Con esta solución, ahora parece que, después de todo, el comportamiento de devolución está en consonancia con mis objetivos.

pwilcox avatar Jan 24 '2015 00:01 pwilcox

Aquí hay un método de extensión que hace eso:

public static IEnumerable<KeyValuePair<TLeft, TRight>> FullOuterJoin<TLeft, TRight>(this IEnumerable<TLeft> leftItems, Func<TLeft, object> leftIdSelector, IEnumerable<TRight> rightItems, Func<TRight, object> rightIdSelector)
{
    var leftOuterJoin = from left in leftItems
        join right in rightItems on leftIdSelector(left) equals rightIdSelector(right) into temp
        from right in temp.DefaultIfEmpty()
        select new { left, right };

    var rightOuterJoin = from right in rightItems
        join left in leftItems on rightIdSelector(right) equals leftIdSelector(left) into temp
        from left in temp.DefaultIfEmpty()
        select new { left, right };

    var fullOuterJoin = leftOuterJoin.Union(rightOuterJoin);

    return fullOuterJoin.Select(x => new KeyValuePair<TLeft, TRight>(x.left, x.right));
}
Michael Sander avatar Dec 13 '2014 13:12 Michael Sander