Ir al contenido principal

Arquitectura — Árboles de Expresión LINQ

Qué es un Expression Tree​

Un expression tree (árbol de expresión) es una representación de código como una estructura de datos en memoria. En lugar de compilar el código a instrucciones de máquina directamente, .NET permite capturarlo como un objeto que se puede inspeccionar, transformar y compilar más tarde.

La diferencia con un delegate normal (Func<T, bool>):

// Delegate: el código se compila y ejecuta. No se puede inspeccionar.
Func<User, bool> func = u => u.Age > 18;
func(user); // ejecuta, pero no se puede saber "qué hace"

// Expression tree: el código se representa como datos. Se puede inspeccionar.
Expression<Func<User, bool>> expr = u => u.Age > 18;
// expr.Body es: BinaryExpression { Left = MemberExpression{Age}, Right = ConstantExpression{18} }
// Se puede recorrer, modificar, serializar... o compilar a delegate.
var compiled = expr.Compile(); // ahora se puede ejecutar
compiled(user);

Por qué Vali-Flow usa Expression Trees y no delegates​

La razón principal es EF Core: para que una condición funcione con IQueryable<T> (y se traduzca a SQL), EF Core necesita recibir un Expression<Func<T, bool>>, no un Func<T, bool>.

// Esto funciona con EF Core (traduce a WHERE en SQL):
dbContext.Users.Where(u => u.Age > 18);
// ^^^^^^^^^^^^^^^^
// El compilador captura esto como Expression<Func<User,bool>>
// cuando el parámetro del método es Expression<Func<T,bool>>

// Esto NO funciona con EF Core (el delegate ya fue compilado):
Func<User, bool> func = u => u.Age > 18;
dbContext.Users.Where(func); // error: no se puede traducir a SQL

La segunda razón es la capacidad de inspección: Explain(), el reporte de errores por condición, y el Analyzer VF001 dependen de poder recorrer el árbol de expresiones.


La anatomía de un árbol de expresión​

Cada árbol tiene nodos. El nodo raíz es una LambdaExpression. Los nodos internos pueden ser BinaryExpression, MemberExpression, MethodCallExpression, etc.

Ejemplo: u => u.Age > 18

LambdaExpression
Parameters: [ParameterExpression { Name="u", Type=User }]
Body:
BinaryExpression (NodeType = GreaterThan)
Left:
MemberExpression (Member = User.Age)
Expression:
ParameterExpression { Name="u", Type=User }
Right:
ConstantExpression { Value = 18, Type = int }

En código:

var param = Expression.Parameter(typeof(User), "u");

var ageProperty = Expression.Property(param, nameof(User.Age));
var constant18 = Expression.Constant(18, typeof(int));
var greaterThan = Expression.GreaterThan(ageProperty, constant18);

var lambda = Expression.Lambda<Func<User, bool>>(greaterThan, param);
// equivalente a: u => u.Age > 18

Cómo los componentes construyen expresiones​

Cada método de un componente (como StringExpression.MinLength) construye manualmente el árbol de expresión que representa esa validación.

Ejemplo: MinLength​

La validación "el string no es null y tiene al menos N caracteres" se construye así:

// Lo que hace MinLength internamente:
public TBuilder MinLength(Expression<Func<T, string?>> selector, int min)
{
// Crea un árbol para: x => selector(x) != null && selector(x).Length >= min
var parameter = selector.Parameters[0]; // el parámetro "x" de la expresión

// Nodo: selector(x) != null
var notNull = Expression.NotEqual(
selector.Body,
Expression.Constant(null, typeof(string))
);

// Nodo: selector(x).Length
var lengthProperty = Expression.Property(selector.Body, nameof(string.Length));

// Nodo: selector(x).Length >= min
var minCheck = Expression.GreaterThanOrEqual(
lengthProperty,
Expression.Constant(min, typeof(int))
);

// Nodo raíz: notNull && minCheck
var combined = Expression.AndAlso(notNull, minCheck);

// Envuelve en lambda
var lambda = Expression.Lambda<Func<T, bool>>(combined, parameter);

// Lo pasa al motor central
return _builder.Add(lambda);
}

El resultado es una Expression<Func<T, bool>> que representa x => x.Email != null && x.Email.Length >= 5.


El problema del parámetro compartido​

Cuando Build() combina múltiples condiciones en un solo árbol, surge un problema: cada condición tiene su propio ParameterExpression. Aunque se llamen igual ("x" o "u"), son objetos distintos en memoria.

Expression<Func<User, bool>> cond1 = u => u.Age > 18;
Expression<Func<User, bool>> cond2 = u => u.IsActive;
// ^ ^
// cond1.Parameters[0] ≠ cond2.Parameters[0]
// Son dos objetos distintos aunque se llamen igual

Si se combinan directamente sin unificar parámetros:

// INCORRECTO: el árbol referencia dos parámetros distintos
var bad = Expression.AndAlso(cond1.Body, cond2.Body);
var badLambda = Expression.Lambda<Func<User, bool>>(bad, cond1.Parameters[0]);
// cond2.Body referencia cond2.Parameters[0], que no está en la firma de badLambda
// Esto lanza InvalidOperationException al compilar

La solución es reemplazar todos los parámetros por uno solo antes de combinar. Esto lo hace ParameterReplacer:

// CORRECTO
var unifiedParam = Expression.Parameter(typeof(User), "x");

var body1 = new ParameterReplacer(cond1.Parameters[0], unifiedParam).Visit(cond1.Body);
var body2 = new ParameterReplacer(cond2.Parameters[0], unifiedParam).Visit(cond2.Body);

var combined = Expression.AndAlso(body1, body2);
var lambda = Expression.Lambda<Func<User, bool>>(combined, unifiedParam);
// Correcto: un solo parámetro, todos los nodos lo usan

Condiciones con MethodCall​

Algunos métodos generan expresiones con llamadas a métodos (MethodCallExpression). Por ejemplo, IsEmail usa Regex.IsMatch:

// IsEmail construye internamente algo equivalente a:
// x => Regex.IsMatch(x.Email, emailPattern)

var regexIsMatch = typeof(Regex).GetMethod(
nameof(Regex.IsMatch),
new[] { typeof(string), typeof(string) }
);

var expr = Expression.Call(
regexIsMatch,
selector.Body, // primer argumento: x.Email
Expression.Constant(emailPattern) // segundo argumento: el pattern
);

Este tipo de expresión no es traducible por EF Core porque SQL no tiene Regex.IsMatch. Por eso IsEmail existe solo en ValiFlow<T> y no en ValiFlowQuery<T>.


Condiciones con acceso a propiedades de navegación​

ValidateNested construye expresiones para validar propiedades de navegación (objetos anidados):

var rule = new ValiFlow<Order>()
.ValidateNested(
o => o.Customer, // selector: Order.Customer
customer => customer
.NotNull(c => c.Email) // condición sobre Customer
);

Internamente, ValidateNested construye un árbol que:

  1. Compila las condiciones del sub-builder (para Customer)
  2. Crea un null-check para el selector (o.Customer != null)
  3. Sustituye el parámetro del sub-árbol por el acceso de navegación (o.Customer)
  4. Combina el null-check con el sub-árbol
// Resultado aproximado:
// o => o.Customer != null && o.Customer.Email != null

Para el punto 3, se necesita ForceCloneVisitor porque el selector body (o.Customer) puede aparecer en dos posiciones del árbol final (en el null-check y como argumento del sub-árbol). Si ambas posiciones compartieran el mismo nodo, el árbol sería inválido. ForceCloneVisitor crea copias estructuralmente idénticas pero con distintas instancias de nodo.


Compilar vs. no compilar​

Expression.Compile() convierte un árbol de expresión en un delegate ejecutable. Es una operación costosa (comparable en tiempo a JIT-compilar un método). Por eso Vali-Flow usa compilación diferida y cacheada:

Primera llamada a IsValid(user):
1. Build() construye el árbol (rápido)
2. Compile() genera el delegate (costoso, ~1ms)
3. El delegate se guarda en _cachedFunc
4. Se ejecuta: _cachedFunc(user) (muy rápido)

Llamadas siguientes a IsValid(user):
1. _cachedFunc ya existe, se omiten los pasos 1-3
2. Se ejecuta: _cachedFunc(user) (muy rápido)

Para Validate(), cada ConditionEntry<T> tiene su propio Lazy<Func<T, bool>>. La compilación ocurre la primera vez que esa condición específica se evalúa, y el resultado se cachea para siempre. Si una condición nunca falla (no se necesita su delegate), nunca se compila.


Resumen: el ciclo de vida de una expresión en Vali-Flow​

1. CAPTURA (en el método del componente)
El usuario escribe: u => u.Age > 18
C# captura esto como Expression<Func<User,bool>>
El componente puede extraer y modificar los nodos del árbol

2. ALMACENAMIENTO (en ConditionEntry)
La expresión se guarda como dato en ConditionEntry<T>
Junto con sus metadatos: operador AND/OR, mensaje, código de error

3. COMBINACIÓN (en BaseExpression.Build)
Las expresiones individuales se combinan en un solo árbol
Los parámetros se unifican con ParameterReplacer
El resultado es una Expression<Func<T,bool>> completa

4. COMPILACIÓN (diferida y cacheada)
Expression.Compile() genera un Func<T,bool>
Se cachea para evitar repetir el paso costoso

5. EJECUCIÓN
compiled(instance) evalúa el árbol contra una instancia concreta
Retorna true/false