Internals — Expression Visitors
Qué es ExpressionVisitor
ExpressionVisitor es una clase base del framework .NET (en System.Linq.Expressions) que implementa el patrón Visitor para recorrer un árbol de expresiones. Provee un método Visit(Expression node) que llama al método apropiado según el tipo del nodo (VisitBinary, VisitMember, VisitParameter, etc.).
Para crear un visitor personalizado, se hereda de ExpressionVisitor y se sobreescriben solo los métodos de los tipos de nodo que interesan. Los nodos no sobreescritos se recorren de forma transparente (el visitor los visita pero retorna el mismo nodo sin cambios).
// Un visitor que no hace nada (el árbol sale igual que entra):
class NoopVisitor : ExpressionVisitor { }
// Un visitor que solo modifica los nodos binarios:
class NegateComparisonsVisitor : ExpressionVisitor
{
protected override Expression VisitBinary(BinaryExpression node)
{
if (node.NodeType == ExpressionType.GreaterThan)
return Expression.LessThan(node.Left, node.Right); // invierte
return base.VisitBinary(node); // deja los demás igual
}
}
Vali-Flow tiene tres visitors propios, cada uno con una responsabilidad específica.
ParameterReplacer
Qué hace
Recorre un árbol de expresión y reemplaza todas las ocurrencias de un ParameterExpression específico por otra expresión. La expresión de reemplazo puede ser cualquier Expression (no necesariamente otro ParameterExpression).
Código
// En Utils/ExpressionHelpers.cs
internal sealed class ParameterReplacer : ExpressionVisitor
{
private readonly ParameterExpression _old;
private readonly Expression _new;
internal ParameterReplacer(ParameterExpression old, Expression @new)
{
_old = old;
_new = @new;
}
protected override Expression VisitParameter(ParameterExpression node)
=> node == _old ? _new : base.VisitParameter(node);
// ^^^^^^^^^^^^^^^^
// Comparación por referencia: ¿es el mismo objeto?
// Si sí, retorna el nodo de reemplazo
// Si no, retorna el nodo original (sin cambios)
}
Por qué acepta Expression y no ParameterExpression como reemplazo
La firma es ParameterReplacer(ParameterExpression old, Expression new) — el parámetro new es Expression, no ParameterExpression. Esto permite usarlo para un caso especial en ValiFlowGlobal:
Cuando se registra un filtro global para una interfaz (ej: Register<ISoftDeletable>(...)), y luego se aplica a un tipo concreto (ej: User que implementa ISoftDeletable), el parámetro de la lambda del filtro (ISoftDeletable) debe ser reemplazado por una expresión de cast:
// Filtro registrado para la interfaz:
Expression<Func<ISoftDeletable, bool>> filter = x => !x.IsDeleted;
// Al aplicarlo a User, el parámetro ISoftDeletable debe convertirse en:
// (User x) => !((ISoftDeletable)x).IsDeleted
// ^^^^^^^^^^^^^^^^^^
// Esto es un Expression.Convert, no un ParameterExpression
// ParameterReplacer reemplaza el parámetro x (ISoftDeletable)
// por la expresión Expression.Convert(userParam, typeof(ISoftDeletable))
var castExpression = Expression.Convert(userParam, typeof(ISoftDeletable));
var replaced = new ParameterReplacer(filter.Parameters[0], castExpression)
.Visit(filter.Body);
Si new fuera ParameterExpression, este caso sería imposible.
Dónde se usa
BaseExpression.Build(): para unificar el parámetro de cada condición con el parámetro compartido del árbol finalBaseExpression.When()yUnless(): para insertar el parámetro correcto en la condición condicionalBaseExpression.BuildNestedExpression(): para reemplazar el parámetro del sub-builder con el acceso de navegaciónBaseExpression.BuildWithGlobal(): para adaptar los filtros globales al tipo concreto
ForceCloneVisitor
Qué hace
Produce una copia estructuralmente idéntica pero con instancias de nodo completamente distintas. Todos los nodos del árbol resultante son nuevos objetos, aunque su estructura y valores son iguales al original.
Código
// En Utils/ExpressionHelpers.cs
internal sealed class ForceCloneVisitor : ExpressionVisitor
{
protected override Expression VisitMember(MemberExpression node)
{
var expr = Visit(node.Expression);
return Expression.MakeMemberAccess(expr, node.Member);
// Nuevo MemberExpression con la misma propiedad
}
protected override Expression VisitUnary(UnaryExpression node)
{
var operand = Visit(node.Operand)!;
return Expression.MakeUnary(node.NodeType, operand, node.Type, node.Method);
// Nuevo UnaryExpression con el mismo operador
}
protected override Expression VisitBinary(BinaryExpression node)
{
var left = Visit(node.Left)!;
var right = Visit(node.Right)!;
return node.Conversion != null
? Expression.MakeBinary(node.NodeType, left, right,
node.IsLiftedToNull, node.Method, (LambdaExpression)Visit(node.Conversion)!)
: Expression.MakeBinary(node.NodeType, left, right,
node.IsLiftedToNull, node.Method);
// Nuevo BinaryExpression con los mismos operandos clonados
}
protected override Expression VisitMethodCall(MethodCallExpression node)
{
var obj = node.Object != null ? Visit(node.Object) : null;
var args = node.Arguments.Select(a => Visit(a)!);
return Expression.Call(obj, node.Method, args);
// Nuevo MethodCallExpression con el mismo método
}
}
Por qué es necesario
El problema surge en ValidateNested. Cuando se valida una propiedad de navegación, el árbol final necesita usar el selector (ej: order.Customer) en dos lugares distintos:
- El null-check:
order.Customer != null - Como "parámetro" del sub-árbol:
order.Customer.Email != null
Si ambos usan el mismo nodo MemberExpression order.Customer, el árbol sería inválido: un nodo del árbol de expresión no puede aparecer en dos posiciones distintas.
// El árbol final que queremos:
// order => order.Customer != null && order.Customer.Email != null
// ^^^^^^^^ ^^^^^^^^
// Posición 1 del null-check Posición 2 del sub-árbol
// Deben ser nodos distintos (aunque representen lo mismo)
// ForceCloneVisitor crea una copia del MemberExpression para la segunda posición:
var selectorBody = selector.Body; // order.Customer (original)
var selectorBodyClone = new ForceCloneVisitor() // order.Customer (copia)
.Visit(selectorBody)!;
Dónde se usa
Exclusivamente en BuildNestedExpression() dentro de BaseExpression, para el caso de ValidateNested.
ExpressionExplainer
Qué hace
Convierte un árbol de expresión en una cadena de texto legible. El resultado no es código C# exacto, sino una representación simplificada diseñada para ser entendida por humanos en logs y mensajes de debugging.
Ejemplo de output
var rule = new ValiFlow<User>()
.GreaterThan(u => u.Age, 18)
.IsTrue(u => u.IsActive)
.Or()
.IsTrue(u => u.IsAdmin);
rule.Explain();
// Output: "((x.Age > 18) AND (x.IsActive == True)) OR (x.IsAdmin == True)"
Cómo funciona
ExpressionExplainer hereda de ExpressionVisitor y acumula texto en un StringBuilder. Para cada tipo de nodo, genera la representación textual correspondiente:
// Pseudocódigo de los métodos principales:
protected override Expression VisitBinary(BinaryExpression node)
{
_sb.Append("(");
Visit(node.Left);
_sb.Append($" {NodeTypeToSymbol(node.NodeType)} ");
Visit(node.Right);
_sb.Append(")");
return node;
}
protected override Expression VisitMember(MemberExpression node)
{
// Si es acceso a propiedad de un parámetro: "x.PropertyName"
if (node.Expression is ParameterExpression param)
_sb.Append($"{param.Name}.{node.Member.Name}");
else
{
Visit(node.Expression);
_sb.Append($".{node.Member.Name}");
}
return node;
}
protected override Expression VisitConstant(ConstantExpression node)
{
_sb.Append(node.Value?.ToString() ?? "null");
return node;
}
protected override Expression VisitMethodCall(MethodCallExpression node)
{
// Muestra: "MethodName(arg1, arg2)"
_sb.Append($"{node.Method.Name}(");
for (int i = 0; i < node.Arguments.Count; i++)
{
if (i > 0) _sb.Append(", ");
Visit(node.Arguments[i]);
}
_sb.Append(")");
return node;
}
Mapeo de NodeType a símbolo
| NodeType | Símbolo en Explain() |
|---|---|
AndAlso | AND |
OrElse | OR |
GreaterThan | > |
GreaterThanOrEqual | >= |
LessThan | < |
LessThanOrEqual | <= |
Equal | == |
NotEqual | != |
Not | NOT |
Dónde se usa
BaseExpression.Explain() llama a ExpressionExplainer:
v2.0.0: Dos ramas
else-if/elseduplicadas enVisitMethodCall— con cuerpos idénticos — fueron colapsadas en un únicoelse. Sin cambio de comportamiento; fue una eliminación de código muerto.
public string Explain()
{
var expr = Build();
return ExpressionExplainer.Explain(expr);
}
Relación entre los tres visitors
loading...Cómo crear un visitor propio
Si se necesita agregar un visitor personalizado (para un caso de uso específico):
- Heredar de
ExpressionVisitor - Sobreescribir los métodos de los tipos de nodo que interesan
- Llamar
base.VisitXxx(node)para los nodos que no se modifican - Usar
Visit(subNode)para recorrer sub-expresiones recursivamente
// Ejemplo: un visitor que cuenta cuántos accesos a propiedades hay
internal sealed class PropertyAccessCounter : ExpressionVisitor
{
public int Count { get; private set; }
protected override Expression VisitMember(MemberExpression node)
{
if (node.Expression is ParameterExpression)
Count++;
return base.VisitMember(node);
}
}
// Uso:
var counter = new PropertyAccessCounter();
counter.Visit(rule.Build());
Console.WriteLine(counter.Count);