Saltar al contenido

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript... y lo que venga ;)

17 años online

el blog de José M. Aguilar

Inicio El autor Contactar

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web
ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript...

¡Microsoft MVP!
martes, 25 de junio de 2019
Entity Framework Pues hablábamos hace unos días del extensor TagWith() de Entity Framework Core que, como recordaréis, venía de perlas para incluir un comentario en las consultas SQL enviadas al servidor de datos para poder identificarlas a posteriori:
LINQ
============================================
var query = context.Friends
    .OrderByDescending(friend => friend.Age)
    .Take(10)
    .Select(friend =>
        new { FriendName = friend.Name, 
              friend.Age, 
              CountryName = friend.Country.Name 
     })
    .TagWith("Get top 10 older friends with country");

Generated SQL
============================================
-- Get top 10 older friends with country

SELECT TOP(@__p_0) [friend].[Name] AS [FriendName], [friend].[Age], 
                   [friend.Country].[Name] AS [CountryName]
FROM [Friends] AS [friend]
LEFT JOIN [Countries] AS [friend.Country] 
     ON [friend].[CountryId] = [friend.Country].[Id]
ORDER BY [friend].[Age] DESC
Mientras escribía el post, pensaba que quizás sería interesante poder utilizar esta misma idea en Entity Framework 6, porque, al fin y al cabo, todos tenemos por ahí muchas aplicaciones en producción que continúan delegando en este marco de trabajo su acceso a datos. Tras una búsqueda en Google y no encontrar gran cosa, pensé que tampoco sería tan difícil de implementar algo que diera el apaño...
TL;DR: Echad un vistazo a EF6.TagWith en NuGet o en el repo de GitHub.

1. Enfocando la solución

Manos a la obra. Conceptualmente, lo que pretendemos hacer es bastante sencillo:
  • Queremos que, en cualquier punto de una consulta LINQ, un desarrollador pueda invocar el extensor TagWith() y especificar una etiqueta personalizada para la misma.

  • A la hora de generar el SQL de la consulta, tenemos que recuperar el valor de dicha etiqueta e insertarla a modo de comentario justo antes del SELECT, como en el ejemplo que veíamos anteriormente.
Seguro que existen muchas formas de implementar algo así, pero mi intención no es meterme en fregados importantes como podría ser la escritura de un proveedor personalizado, o bucear entre los oscuros misterios de la implementación interna de Entity Framework. Necesitamos algo más sencillo, quizás algo hacky, pero que funcione :)

Por ello, la solución propuesta consiste en:
  • Crear un método extensor TagWith() sobre IQueryable, que añadirá a la consulta una "marca" fácilmente identificable para detectar a posteriori que ésta incluye una etiqueta personalizada.

  • A continuación, usando los interceptores de Entity Framework 6, descubrir las queries en las que haya sido introducida la "marca" anterior, extraer la etiqueta y reformular la sentencia SQL para incluir dicha etiqueta como comentario.
Con algo más de detalle, y expresado de forma práctica, la cosa quedaría así:
// 1: Consulta inicial (C#)
var query = _ctx.Friends
               .TagWith("Hello world!")
               .Where(f => f.Id < 10);

// 2: La llamada a TagWith() añade un predicado predefinido a la consulta,
//    de forma que es reescrita de la siguiente manera:
var query = _ctx.Friends
               .Where(f=> "!__tag!" == "Hello world")
               .Where(f => f.Id < 10);

// 3: Debido a lo anterior, el proveedor genera la siguiente consulta SQL:
SELECT 
    [Extent1].[Id] AS [Id], 
    [Extent1].[Name] AS [Name], 
    [Extent1].[Country_Id] AS [Country_Id]
    FROM [dbo].[Friends] AS [Extent1]
    WHERE (N'!__tag!' = N'Hello world!') AND ([Extent1].[Id] < 10)

// 4: En un interceptor de EF detectamos la subcadena "!__tag!", por lo que sabemos
//    que se trata de una consulta etiquetada. Manipulando un poco la cadena,
//    transformamos la consulta SQL en la siguiente, donde se incluye la etiqueta como 
//    comentario y ha desaparecido el predicado falso introducido con TagWith():
-- Hello world!
SELECT 
    [Extent1].[Id] AS [Id], 
    [Extent1].[Name] AS [Name], 
    [Extent1].[Country_Id] AS [Country_Id]
    FROM [dbo].[Friends] AS [Extent1]
    WHERE ([Extent1].[Id] < 10)
Veamos ahora cómo implementar todo esto.

2. Implementación del extensor TagWith()

Esta parte es la más sencilla. El extensor TagWith() simplemente debe añadir al IQueryable<T> una nueva cláusula where fácilmente reconocible a posteriori:
public static class TagWithExtensions
{
    public static string TagMarker = "!__tag!";

    public static IQueryable<T> TagWith<T>(this IQueryable<T> query, string tag)
    {
        var tagConstant = Expression.Constant(TagMarker);
        var tagValue = Expression.Constant(tag);
        var equalsExpression = Expression.Equal(tagConstant, tagValue);
        var predicate = Expression.Lambda<Func<T, bool>>(
                equalsExpression, Expression.Parameter(typeof(T))
        );
        return query.Where(predicate);
    }
}
Como se observa en el código anterior, generamos un predicado construyendo un árbol de expresión en el que comparamos dos constantes, "!__tag!" y la etiqueta indicada en la llamada al método.

El resultado de dicha comparación será siempre falso, pero en realidad esto no nos importa porque nunca irá al servidor de datos: eliminaremos este predicado de la sentencia SQL generada justo antes de ejecutarse. Veamos cómo.

3. Modificando la consulta

Entity Framework 6 incluye un interesante mecanismo que nos permite consultar y modificar el SQL generado por el proveedor antes de que sea enviado al servidor de datos: los interceptores de comandos.

Estos componentes, que implementan la interfaz IDbCommandInterceptor, permiten tomar el control antes de ejecutar una consulta para analizar o cambiar el SQL generado. Nosotros nos valdremos de ello para determinar si estamos ante una consulta etiquetada y, en su caso, modificarla para adaptarla a nuestros intereses.
public class QueryTaggerInterceptor : DbCommandInterceptor
{
    private readonly ISqlTagger _sqlTagger;

    public QueryTaggerInterceptor(ISqlTagger sqlTagger)
    {
        _sqlTagger = sqlTagger;
    }

    public override void ReaderExecuting(DbCommand command, 
                DbCommandInterceptionContext<DbDataReader> context)
    {
            do
            {
                command.CommandText = _sqlTagger.GetTaggedSqlQuery(command.CommandText);
            } 
            while (command.CommandText.IndexOf(
                    TagWithExtensions.TagMarker, 
                    StringComparison.Ordinal) > -1);
    }
}
Ese componente de tipo ISqlTagger que usamos en el interceptor simplemente contiene la operación GetTaggedSqlQuery(), que recibe la cadena de texto con la consulta SQL y la retorna modificada según la siguiente lógica:
  • Si la SQL no contiene la marca "!__tag!", es que estamos ante una consulta no etiquetada, por lo que la dejamos tal cual. En caso contrario, asumimos que la consulta está tageada, por lo que continuamos el procedimiento.

  • Extraemos la etiqueta indicada por el usuario en la llamada a TagWith().

  • Eliminamos de la SQL el predicado completo.

  • Y, por último, fijaos que el proceso se repite siempre qe sigamos encontrando marcas en la consulta, puesto que un desarrollador podría haber incluido más de una llamada a Tagwith() sobre la misma.
La definición de la interfaz ISqlTagger es la siguiente:
public interface ISqlTagger
{
    string GetTaggedSqlQuery(string sql);
}
El motivo de utilizar una interfaz es que el formato de la consulta SQL podría variar de un proveedor a otro debido a matices sintácticos, y de esta forma podríamos soportar en el futuro distintas implementaciones. Por ejemplo, el siguiente código muestra cómo sería el taggeador para SQL Server:
public class SqlServerTagger: ISqlTagger
{
    public string GetTaggedSqlQuery(string sql)
    {
        public string GetTaggedSqlQuery(string sql)
        {
            var indexOfTagConstant = sql.IndexOf(
                TagWithExtensions.TagMarker, StringComparison.Ordinal
            );
            if (indexOfTagConstant == -1)
                return sql;

            var predicateStartIndex = indexOfTagConstant - 2;
            var startOfTagIndex = predicateStartIndex + TagWithExtensions.TagMarker.Length + 8;

            var predicateEndIndex = sql.IndexOf("'", startOfTagIndex, StringComparison.Ordinal);
            var endOfTagIndex = predicateEndIndex - 1; // Remove the final single quote

            var tag = sql.Substring(startOfTagIndex, endOfTagIndex - startOfTagIndex + 1);

            var startsWithAnd = CmpStr(sql, predicateStartIndex - 5, "AND (");
            var endsWithAnd = CmpStr(sql, endOfTagIndex + 2, ") AND");

            string finalSql;
            if (startsWithAnd)
            {
                // Predicate pattern: ... AND (N'__tag' = N'mytag')
                finalSql = sql.Substring(0, indexOfTagConstant - 8) 
                           + sql.Substring(endOfTagIndex + 3);
            }
            else if (endsWithAnd)
            {
                // Predicate pattern: (N'__tag' = N'mytag') AND ...
                finalSql = sql.Substring(0, indexOfTagConstant - 3) 
                           + sql.Substring(endOfTagIndex + 8);
            }
            else
            {
                // It is the only predicate, so remove the whole "Where" section
                finalSql = sql.Substring(0, indexOfTagConstant - 8)
                           + sql.Substring(predicateEndIndex + 1);
            }

            finalSql = AddTagToQuery(finalSql, tag);
            return finalSql;
        }

        private string AddTagToQuery(string sql, string tag)
        {
            [...] // Inserts the tag in the SQL query
        }

        private static bool CmpStr(string str, int startIndex, string compare)
        {
            [...] // Detects if a substring is contained in another string
        }
}

Por último, los interceptores podemos registrarlos en el sistema de varias formas: desde el archivo de configuración del proyecto, en la configuración del contexto de datos, o globalmente. Por simplificar, lo haremos de esta última manera, introduciendo el siguiente código en el arranque de la aplicación (Por ejemplo, el global.asax.cs si se trata de una aplicación web):
DbInterception.Add(new QueryTaggerInterceptor(new SqlServerTagger()));

4. ¡Quiero probarlo!

He colgado el proyecto completo en Github por si queréis echar un vistazo al componente o ver algunos ejemplos de uso (en los tests, básicamente). Y para facilitar aún más las cosas, he creado el paquete NuGet EF6.TagWith, que podéis usar sin tener que copiar y pegar nada ;)

Para utilizarlo sólo tenéis que instalarlo en un proyecto donde uséis Entity Framework 6:
PM> install-package EF6.TagWith
A continuación, debéis registrar el interceptor en la inicialización de vuestra aplicación, tal y como hemos visto antes:
DbInterception.Add(new QueryTaggerInterceptor(new SqlServerTagger()));
Y hecho esto, taggear algunas consultas y utilizar alguna herramienta como SQL Profiler para observar el resultado:
var query = ctx.Friends
    .OrderByDescending(friend => friend.Age)
    .Take(10)
    .Select(friend => new { FriendName = friend.Name, CountryName = friend.Country.Name })
    .TagWith("Get top 10 older friends with country");

SQL Profiler mostrando queries etiquetadas

Espero que os sea de utilidad :)

Publicado en: www.variablenotfound.com.

2 Comentarios:

vtortola dijo...

Conoces alguna forma de taggear de forma que se pueda ver en el Query Store?

José María Aguilar dijo...

Hola!

Ostras, pues no sabría decirte, ni siquiera he probado por ese camino... a ver si un día de estos tengo un ratillo y le echo un vistazo.

Saludos!