using System.Linq.Expressions; using System.Reflection; using Greencore.platform.rules; namespace Greencore.platform.validators; public static class Validation { public static ValidationBuilder For() => new(); public static ValidationBuilder For() => new(); } /// Constructor mutable de configuración; Build publica una instantánea independiente. public sealed class ValidationBuilder { private readonly List> _nodes = []; public ValidationBuilder Add(IValidationDefinition rule, string? nodeId = null) => Append(Leaf(rule, nodeId)); public ValidationBuilder Add(ILocalValidationDefinition rule, string? nodeId = null) => Add(Lift(rule), nodeId); public ValidationBuilder Require(IValidationDefinition rule, string? nodeId = null) => Add(rule, nodeId); public ValidationBuilder Require(ILocalValidationDefinition rule, string? nodeId = null) => Add(rule, nodeId); /// La nueva regla depende únicamente de la última rama registrada. public ValidationBuilder Then(IValidationDefinition rule, string? nodeId = null) => ThenNode(Leaf(rule, nodeId)); public ValidationBuilder Then(ILocalValidationDefinition rule, string? nodeId = null) => Then(Lift(rule), nodeId); public ValidationBuilder Then(Action> configure) => ThenNode(Group(RuleNodeKind.AllOf, configure)); public ValidationBuilder AllOf(Action> configure) => Append(Group(RuleNodeKind.AllOf, configure)); /// Cada rama depende del éxito de todas las ramas anteriores de la secuencia. public ValidationBuilder Sequence(Action> configure) { var nodes = Group(RuleNodeKind.AllOf, configure).Children; var chain = nodes[0]; foreach (var node in nodes.Skip(1)) chain = new(RuleNodeKind.Prerequisite, [chain, node]); return Append(chain); } /// Incluye una composición con identidades nuevas por aparición, conservando las rutas. public ValidationBuilder Include(ValidationPlan plan) { ArgumentNullException.ThrowIfNull(plan); return Append(Import(plan.Tree, plan.Diagnostics)); } public ValidationBuilder OptionalObject(Expression> property, Action> configure) where V : class { var path = ValidationPath.Get(property); var select = property.Compile(); var child = Map(ValidationBuilder.Group(RuleNodeKind.AllOf, configure), value => select(value)!, path); var gate = new ConfiguredRule(RuleNodeKind.Leaf, [], RuleDefinition.For("object.present", (value, _) => select(value) is not null), Path: path); return Append(new(RuleNodeKind.When, [gate, child])); } public ValidationBuilder RequiredObject(Expression> property, Action> configure, string code, string message) where V : class { var path = ValidationPath.Get(property); var select = property.Compile(); var child = Map(ValidationBuilder.Group(RuleNodeKind.AllOf, configure), value => select(value)!, path); var gate = new ConfiguredRule(RuleNodeKind.Leaf, [], RuleDefinition.For("object.required", (value, _) => select(value) is not null), new(code, message), Path: path); return Append(new(RuleNodeKind.Prerequisite, [gate, child])); } public ValidationBuilder AnyOf(Action> configure, string code, string message) => Append(Group(RuleNodeKind.AnyOf, configure, new(code, message))); public ValidationBuilder Not(Action> configure, string code, string message) => Append(new(RuleNodeKind.Not, [Group(RuleNodeKind.AllOf, configure)], Diagnostic: new(code, message))); /// La condición afecta solamente al contenido del callback. public ValidationBuilder When(Func condition, Action> configure) { ArgumentNullException.ThrowIfNull(condition); return When((value, _) => condition(value), configure); } public ValidationBuilder When(Func condition, Action> configure) { ArgumentNullException.ThrowIfNull(condition); var gate = new ConfiguredRule(RuleNodeKind.Leaf, [], RuleDefinition.For("condition", condition)); return Append(new(RuleNodeKind.When, [gate, Group(RuleNodeKind.AllOf, configure)])); } public ValidationBuilder Unless(Func condition, Action> configure) { ArgumentNullException.ThrowIfNull(condition); return When(value => !condition(value), configure); } public ValidationBuilder Field(Expression> property, Action> configure) { var path = ValidationPath.Get(property); var selector = property.Compile(); var group = ValidationBuilder.Group(RuleNodeKind.AllOf, configure); return Append(Map(group, selector, path)); } /// Valida un campo en secuencia; cada regla depende del éxito de las anteriores. public ValidationBuilder FieldSequence(Expression> property, Action> configure) { ArgumentNullException.ThrowIfNull(configure); return Field(property, field => field.Sequence(configure)); } public ValidationPlan Build() { if (_nodes.Count == 0) throw new InvalidOperationException("Configure at least one rule."); var registrations = new Dictionary(StringComparer.Ordinal); var root = new ConfiguredRule(RuleNodeKind.AllOf, _nodes.ToArray()); var explicitIds = new HashSet(StringComparer.Ordinal); CollectIds(root, explicitIds); var next = 0; var tree = Materialize(root, registrations, explicitIds, ref next); return new(tree, new RuleDiagnostics(registrations)); } private static void CollectIds(ConfiguredRule node, HashSet ids) { if (node.NodeId is { } id && !ids.Add(id)) throw new ArgumentException($"Duplicate node id: {id}."); foreach (var child in node.Children) CollectIds(child, ids); } private static ConfiguredRule Import(RuleNode node, RuleDiagnostics diagnostics) => new(node.Kind, node.Children.Select(child => Import(child, diagnostics)).ToArray(), node.Definition, diagnostics.Find(node.NodeId), Path: node.Path); private static RuleNode Materialize(ConfiguredRule node, Dictionary diagnostics, HashSet ids, ref int next) { var id = node.NodeId; if (id is null) { do { id = $"node.{next++}"; } while (!ids.Add(id)); } if (node.Diagnostic is { } diagnostic) diagnostics.Add(id, diagnostic); var children = new List>(); foreach (var child in node.Children) children.Add(Materialize(child, diagnostics, ids, ref next)); return new(id, node.Kind, children, node.Definition, node.Path); } private static ConfiguredRule Map(ConfiguredRule node, Func selector, string prefix) => new(node.Kind, node.Children.Select(child => Map(child, selector, prefix)).ToArray(), node.Definition?.Select(selector), node.Diagnostic, node.NodeId, node.Path is null ? prefix : $"{prefix}.{node.Path}"); private static IValidationDefinition Lift(ILocalValidationDefinition rule) { ArgumentNullException.ThrowIfNull(rule); return new ValidationDefinition(rule.Definition.WithContext(), rule.Diagnostic); } private static ConfiguredRule Leaf(IValidationDefinition rule, string? nodeId) { ArgumentNullException.ThrowIfNull(rule); ArgumentNullException.ThrowIfNull(rule.Definition); ArgumentNullException.ThrowIfNull(rule.Diagnostic); if (nodeId is not null) ArgumentException.ThrowIfNullOrWhiteSpace(nodeId); return new(RuleNodeKind.Leaf, [], rule.Definition, rule.Diagnostic, nodeId); } private static ConfiguredRule Group(RuleNodeKind kind, Action> configure, RuleDiagnosticDefinition? diagnostic = null) { ArgumentNullException.ThrowIfNull(configure); var builder = new ValidationBuilder(); configure(builder); if (builder._nodes.Count == 0) throw new ArgumentException("A group must contain at least one rule.", nameof(configure)); return new(kind, builder._nodes.ToArray(), Diagnostic: diagnostic); } private ValidationBuilder Append(ConfiguredRule node) { _nodes.Add(node); return this; } private ValidationBuilder ThenNode(ConfiguredRule node) { if (_nodes.Count == 0) throw new InvalidOperationException("Then requires a preceding rule or group."); _nodes[^1] = new(RuleNodeKind.Prerequisite, [_nodes[^1], node]); return this; } } internal sealed record ConfiguredRule(RuleNodeKind Kind, IReadOnlyList> Children, RuleDefinition? Definition = null, RuleDiagnosticDefinition? Diagnostic = null, string? NodeId = null, string? Path = null); internal static class ValidationPath { internal static string Get(Expression> property) { ArgumentNullException.ThrowIfNull(property); Expression? cursor = property.Body; while (cursor is UnaryExpression { NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked } conversion) cursor = conversion.Operand; var names = new Stack(); while (cursor is MemberExpression { Member: PropertyInfo } member) { names.Push(member.Member.Name); cursor = member.Expression; } if (names.Count == 0 || cursor != property.Parameters[0]) throw new ArgumentException("Select a property path rooted in the candidate.", nameof(property)); return string.Join(".", names); } }