Ir al contenido principal

Guía — Cómo agregar un nuevo método a la librería

Esta guía describe el proceso completo para agregar un nuevo método de validación a Vali-Flow.Core. Se usa como ejemplo agregar un método IsPostalCode al dominio de strings.


Checklist (resumen)

Antes de empezar, tener a mano este checklist. Cada paso corresponde a una sección de la guía:

  • 1. Implementar en la clase del componente (Classes/Types/)
  • 2. Declarar en la interfaz del tipo (Interfaces/Types/)
  • 3. Agregar a ValiFlow<T> (el source generator lo hace automáticamente)
  • 4. Decidir si va en ValiFlowQuery<T> (¿es EF Core-safe?)
  • 5. Escribir tests
  • 6. Ejecutar dotnet test — 0 fallos

Paso 1: Implementar en la clase del componente

El primer paso es escribir la lógica real en la clase del componente correspondiente. Para un método de validación de strings, el archivo es Vali-Flow.Core/Classes/Types/StringExpression.cs.

La clase del componente sigue siempre la misma estructura:

// Dentro de StringExpression<TBuilder, T>

/// <summary>Valida que el valor es un código postal argentino (4 o 5 dígitos).</summary>
/// <remarks>Not EF Core translatable. Uses Regex internally.</remarks>
public TBuilder IsPostalCode(Expression<Func<T, string?>> selector)
{
// 1. Obtener el parámetro del selector (representa "x" en x => x.ZipCode)
var parameter = selector.Parameters[0];

// 2. Construir el árbol de expresión
// Este árbol representa: x => x.ZipCode != null && Regex.IsMatch(x.ZipCode, pattern)
var notNull = Expression.NotEqual(
selector.Body,
Expression.Constant(null, typeof(string))
);

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

var regexCall = Expression.Call(
regexIsMatch,
selector.Body,
Expression.Constant(@"^\d{4,5}$") // patrón: 4 o 5 dígitos
);

var combined = Expression.AndAlso(notNull, regexCall);
var lambda = Expression.Lambda<Func<T, bool>>(combined, parameter);

// 3. Delegar al builder padre (BaseExpression.Add)
return _builder.Add(lambda);
}

Dónde vive la lógica

Todo el código de construcción de expresiones vive en el componente. ValiFlow<T> no tiene lógica; solo delega.

Acceder a patrones Regex predefinidos

Los patrones comunes están en RegularExpressions/RegularExpression.cs. Si el patrón es nuevo, agregarlo ahí:

// En RegularExpression.cs
internal static class RegularExpression
{
internal static readonly Regex Email = new(@"^[^@\s]+@[^@\s]+\.[^@\s]+$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);

// Nuevo patrón:
internal static readonly Regex PostalCode = new(@"^\d{4,5}$",
RegexOptions.Compiled);
}

Usar el campo de la clase en lugar de crear un new Regex(...) en cada llamada:

var regexCall = Expression.Call(
regexIsMatch,
selector.Body,
Expression.Constant(RegularExpression.PostalCode.ToString())
// O pasar directamente la instancia si el método acepta Regex:
// Expression.Constant(RegularExpression.PostalCode)
);

Paso 2: Declarar en la interfaz del tipo

Abrir Vali-Flow.Core/Interfaces/Types/IStringExpression.cs y agregar la declaración del método.

La interfaz define el contrato. Los consumidores que dependan de la interfaz verán el nuevo método automáticamente.

// En IStringExpression<TBuilder, T> o en una sub-interfaz como IStringFormatExpression
// (elegir la sub-interfaz que mejor agrupe el método)

/// <summary>Valida que el valor es un código postal de 4 o 5 dígitos.</summary>
/// <remarks>Not EF Core translatable. Uses Regex internally.</remarks>
TBuilder IsPostalCode(Expression<Func<T, string?>> selector);

Cuándo poner el <remarks>Not EF Core translatable

Agregar este remark si el método usa cualquiera de estos elementos:

  • Regex.IsMatch
  • string.Contains(string, StringComparison)
  • char.IsLetter, char.IsDigit, char.IsWhiteSpace, etc.
  • Predicados lambda como argumentos (.Any(x => x.Active))
  • Enumerable.Contains con listas en memoria

Si el método genera solo comparaciones simples (igual, mayor que, menor que, acceso a propiedad), es EF Core-safe y no necesita el remark.


Paso 3: ValiFlow<T> — el source generator lo resuelve

Después de agregar el método a la clase del componente y a la interfaz, el source generator detecta el cambio en la próxima compilación y genera automáticamente el método de delegación en ValiFlow.Forwarding.g.cs:

// Generado automáticamente — NO editar:
public ValiFlow<T> IsPostalCode(Expression<Func<T, string?>> selector)
=> _stringExpression.IsPostalCode(selector);

No hay nada que hacer manualmente en ValiFlow.cs.


Paso 4: Decidir si va en ValiFlowQuery<T>

Si el método es EF Core-safe

Abrir Vali-Flow.Core/Builder/ValiFlowQuery.cs y verificar que el campo _stringExpression ya tiene la interfaz correcta. El source generator también generará el método de delegación para ValiFlowQuery<T> automáticamente, siempre que la interfaz esté referenciada en el campo marcado con [ForwardInterface].

Para un método EF Core-safe no hay que hacer nada adicional en ValiFlowQuery.cs.

Si el método NO es EF Core-safe (como IsPostalCode)

El método no debe aparecer en ValiFlowQuery<T>. Para asegurar esto:

  1. Verificar que el remark Not EF Core translatable está en la interfaz
  2. Verificar que ValiFlowQuery.cs usa una interfaz separada (o excluye la sub-interfaz) que no declara el método

Si la sub-interfaz IStringFormatExpression está excluida de ValiFlowQuery<T>, el método no aparecerá allí automáticamente.


Paso 5: Escribir tests

Los tests viven en Vali-Flow.Core.Tests/. Cada dominio tiene su propio archivo de tests (ej: StringExpressionTests.cs).

Estructura de un test:

[Fact]
public void IsPostalCode_ValidCode_ReturnsTrue()
{
// Arrange
var rule = new ValiFlow<User>()
.IsPostalCode(u => u.ZipCode);

var user = new User { ZipCode = "12345" };

// Act
var result = rule.IsValid(user);

// Assert
Assert.True(result);
}

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("ABCDE")]
[InlineData("123")] // muy corto
[InlineData("123456")] // muy largo
public void IsPostalCode_InvalidCode_ReturnsFalse(string? zipCode)
{
var rule = new ValiFlow<User>().IsPostalCode(u => u.ZipCode);
var user = new User { ZipCode = zipCode };

Assert.False(rule.IsValid(user));
}

Qué cubrir en los tests

  • Caso feliz: valor válido que debe pasar
  • Casos de borde: null, cadena vacía, valor mínimo/máximo del rango
  • Casos negativos: valores que claramente deben fallar
  • Combinación con AND/OR (si la lógica es compleja)
  • Integración: que Build() retorna una expresión usable con LINQ

Paso 6: Ejecutar los tests

cd /Users/feliperafaelmontenegro/RiderProjects/Vali-Flow.Core
dotnet test

El resultado debe ser 0 fallos antes de hacer commit.

dotnet test --configuration Release

También ejecutar en Release para detectar diferencias de comportamiento entre Debug y Release (son raras, pero existen con expression trees).


Ejemplo completo: método IsAlphaNumeric (sin Regex, EF Core-safe)

Para mostrar un método que sí va en ValiFlowQuery<T>:

// Paso 1: Implementar en StringExpression.cs
/// <summary>Valida que el valor contiene solo letras y/o dígitos.</summary>
public TBuilder IsAlphaNumericSimple(Expression<Func<T, string?>> selector)
{
var parameter = selector.Parameters[0];

// Versión simple: usar string.All con char.IsLetterOrDigit
// NOTA: char.IsLetterOrDigit no es EF Core-translatable
// Para hacer una versión EF Core-safe, se necesita una comparación diferente

// Versión EF Core-safe: delegar a una columna calculada o usar LIKE
// En EF Core no hay un equivalente directo de "solo alfanumérico"
// Por tanto, este método pertenece solo a ValiFlow<T>
}

Si se descubre que un método no puede ser EF Core-safe de ninguna forma razonable, simplemente no se agrega a ValiFlowQuery<T> y se documenta con el remark.


Errores comunes al agregar métodos

El árbol de expresión referencia variables capturadas incorrectamente

// MAL: la variable 'pattern' es capturada por referencia
// Si su valor cambia después, el árbol puede comportarse de forma inesperada
string pattern = GetPattern();
var expr = (Expression<Func<User, bool>>)(u => Regex.IsMatch(u.Email!, pattern));

// BIEN: usar Expression.Constant para incrustar el valor en el árbol
var lambda = Expression.Lambda<Func<T, bool>>(
Expression.Call(regexMethod, selector.Body, Expression.Constant(pattern)),
parameter
);

Olvidar unificar parámetros antes de combinar

BaseExpression.Add se encarga de esto cuando se agrega la expresión. Pero si dentro del método del componente se combinan múltiples expresiones manualmente antes de llamar _builder.Add, hay que asegurar que todas comparten el mismo ParameterExpression.

// MAL: combinar sin unificar parámetros
var cond1 = BuildCondition1(selector);
var cond2 = BuildCondition2(selector);
var combined = Expression.AndAlso(cond1.Body, cond2.Body);
// cond1 y cond2 pueden tener distintos parámetros

// BIEN: usar el mismo parámetro en todas las sub-expresiones
var param = selector.Parameters[0];
var body1 = BuildBody1(selector.Body, param);
var body2 = BuildBody2(selector.Body, param);
var combined = Expression.AndAlso(body1, body2);
var lambda = Expression.Lambda<Func<T, bool>>(combined, param);

Agregar el método a la interfaz pero no a la clase

Si se agrega IsPostalCode a IStringExpression pero no a StringExpression, el compilador dará un error de "no implementa el miembro de la interfaz". El source generator no puede generar el método de delegación si el componente no lo tiene.