martes, 25 de junio de 2019

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.
Por ello, la solución propuesta consiste en:
-
Crear un método extensor
TagWith()
sobreIQueryable
, 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.
// 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.
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 NuGetEF6.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");

Espero que os sea de utilidad :)
Publicado en: www.variablenotfound.com.
2 Comentarios:
Conoces alguna forma de taggear de forma que se pueda ver en el Query Store?
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!
Enviar un nuevo comentario