Skip to content

Commit 09174a0

Browse files
committed
Improve validation docs, fix collection index, add tests
Documentation, Indexing, and Test Coverage Improvements - Documentation and usage clarity -- Rewrote and expanded README.md in English for clarity, adding detailed sections, updated examples, and best practices for SmartValidations. - Collection validation and property indexing -- Fixed collection item indexing in RuleSet.cs so that property paths use zero-based indices (e.g., Products[0]) in validation errors. -- Updated ValidateTests.cs assertions to expect the correct zero-based property names. - Validation rule test coverage -- Added unit tests in RuleSetTests.Mist.cs for Email and Url rules, covering valid, invalid, null, and property prefix scenarios.
1 parent c218ea6 commit 09174a0

4 files changed

Lines changed: 221 additions & 80 deletions

File tree

README.md

Lines changed: 118 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,33 @@
11
# SmartValidations
22

3-
Biblioteca de validação fluente para .NET, construída sobre SmartProblems, focada em validar propriedades de modelos (especialmente DTOs/requests) e produzir `Problems` quando regras falham.
3+
Fluent, model-first validation for .NET that produces structured Problems instead of exceptions. Built on top of SmartProblems to deliver actionable, localized, and machine-readable validation results.
44

5-
Objetivo
6-
- Validar propriedades de um modelo em uma única passagem usando um `RuleSet`.
7-
- Retornar `Problems` via integração com SmartProblems (Problem, Problems, Result), sem lançar exceções.
8-
- Facilitar validações aninhadas (objetos e coleções) e compor regras de forma fluente.
5+
Why SmartValidations
6+
- Single-pass model validation: express all rules fluently in one `RuleSet`.
7+
- No exceptions for control flow: return `Problems` you can serialize and show to users.
8+
- Strongly-typed and fluent: compile-time safety with `INumber<T>`, `CallerArgumentExpression`, and generics.
9+
- First-class nested validation: validate objects and collections, automatically chaining property paths (with indexes for lists).
10+
- Ready for APIs and UI: consistent error shapes, localization-friendly message templates, and rich metadata.
911

10-
Targets e requisitos
12+
Targets and requirements
1113
- .NET 8, .NET 9, .NET 10.
12-
- C# 12+ com uso de `CallerArgumentExpression` e genéricos com `INumber<T>`.
13-
- Depende de SmartProblems para agregar e enriquecer erros.
14+
- C# 12+ using `CallerArgumentExpression` and generic math (`INumber<T>`).
15+
- Depends on SmartProblems for `Problem` and `Problems`.
1416

15-
Instalação
16-
- Adicione SmartValidations e SmartProblems ao seu projeto (NuGet quando disponível).
17+
Installation
18+
- Add SmartValidations and SmartProblems (NuGet when available).
1719

18-
Conceitos principais
19-
- `Rules.Set()` / `Rules.Set<T>()`: cria um `RuleSet` para aplicar regras.
20-
- `RuleSet`: DSL fluente que acumula `Problems` quando validações falham.
21-
- `BuildInPredicates`: funções utilitárias usadas internamente pelas regras.
22-
- `IValidable` e `ValidateFunc`: pontos de integração para validações aninhadas.
20+
Core concepts
21+
- `Rules.Set()` / `Rules.Set<T>()`: creates a `RuleSet` for applying rules.
22+
- `RuleSet`: fluent DSL that accumulates `Problems` whenever a rule fails.
23+
- `IValidable` and `ValidateFunc`: plug-in points for nested validations.
24+
- Implicit conversion: a `RuleSet` can be treated as `Problems?` or queried via `HasProblems(out var problems)`.
2325

24-
Uso básico
26+
Quick start
2527
```csharp
28+
using RoyalCode.SmartValidations;
29+
using RoyalCode.SmartProblems;
30+
2631
public class CreateOrderRequest
2732
{
2833
public string CustomerName { get; set; } = string.Empty;
@@ -38,105 +43,144 @@ public class CreateOrderRequest
3843
}
3944
```
4045

41-
Validações de strings e padrões
46+
String and pattern rules
4247
```csharp
43-
Rules.Set()
48+
var set = Rules.Set()
4449
.NotEmpty(Name)
4550
.MinLength(Name, 3)
4651
.MaxLength(Name, 100)
4752
.OnlyLettersOrDigits(Username)
4853
.NoWhiteSpace(Username)
4954
.Matches(Email, @"^.+@.+\..+$", "email pattern")
5055
.Email(Email)
51-
.Url(Website)
52-
.HasProblems(out var problems);
56+
.Url(Website);
57+
58+
if (set.HasProblems(out var problems))
59+
{
60+
// Serialize problems to your API response
61+
}
5362
```
5463

55-
Comparações e limites
64+
Comparisons and ranges
5665
```csharp
57-
Rules.Set()
66+
var set = Rules.Set()
5867
.Equal(Code, "ABC", StringComparison.OrdinalIgnoreCase)
5968
.NotEqual(Status, "inactive")
6069
.Min(Age, 18)
6170
.Max(ItemsCount, 100)
6271
.MinMax(Score, 0, 100)
6372
.LessThan(StartDate, EndDate)
64-
.GreaterThanOrEqual(Quantity, 1)
65-
.HasProblems(out var problems);
73+
.GreaterThanOrEqual(Quantity, 1);
6674
```
6775

68-
Regras customizadas (`Must`)
76+
Custom rules with Must
6977
```csharp
70-
Rules.Set()
71-
.Must(Password, p => p.Length >= 8 && p.Any(char.IsDigit),
72-
(prop, val) => $"{prop} must contain at least 8 chars and a digit.")
73-
.BothMust(Start, End, (s, e) => s < e,
74-
(p1, p2, v1, v2) => $"{p1} must be before {p2}.")
75-
.HasProblems(out var problems);
78+
var set = Rules.Set()
79+
.Must(Password,
80+
p => p is { Length: >= 8 } && p.Any(char.IsDigit) && p.Any(char.IsUpper),
81+
(prop, _) => $"{prop} must contain at least 8 chars, an uppercase letter and a digit.",
82+
ruleName: "password.policy")
83+
.BothMust(Start, End,
84+
(s, e) => s < e,
85+
(p1, p2, _, _) => $"{p1} must be before {p2}.",
86+
ruleName: "period.order");
7687
```
7788

78-
Validações aninhadas (objetos)
89+
Nested validation (objects)
7990
```csharp
80-
public class Order : IValidable
91+
public class CheckoutRequest : IValidable
8192
{
82-
public string CustomerName { get; set; } = string.Empty;
83-
public decimal TotalAmount { get; set; }
93+
public string CustomerId { get; set; } = string.Empty;
8494
public Address? ShippingAddress { get; set; }
95+
public Address? BillingAddress { get; set; }
96+
97+
public bool HasProblems(out Problems? problems)
98+
{
99+
return Rules.Set<CheckoutRequest>()
100+
.NotEmpty(CustomerId)
101+
.NotNullNested(ShippingAddress, addr => Rules.Set<Address>()
102+
.WithPropertyPrefix("addr")
103+
.NotEmpty(addr.Street)
104+
.NotEmpty(addr.City)
105+
.NotEmpty(addr.ZipCode)
106+
.NotEmpty(addr.Country))
107+
.Nested(BillingAddress, addr => Rules.Set<Address>()
108+
.WithPropertyPrefix("addr")
109+
.NotEmpty(addr.Street)
110+
.NotEmpty(addr.City)
111+
.NotEmpty(addr.ZipCode)
112+
.NotEmpty(addr.Country))
113+
.HasProblems(out problems);
114+
}
115+
}
116+
```
117+
118+
Nested validation (collections)
119+
```csharp
120+
public class Order : IValidable
121+
{
122+
public List<OrderItem>? Items { get; set; }
85123

86124
public bool HasProblems(out Problems? problems)
87125
{
88126
return Rules.Set<Order>()
89-
.NotEmpty(CustomerName)
90-
.GreaterThan(TotalAmount, 0)
91-
.NotNullNested(ShippingAddress, address => Rules.Set<Address>()
92-
.WithPropertyPrefix("address")
93-
.NotEmpty(address.Street)
94-
.NotEmpty(address.City)
95-
.NotEmpty(address.ZipCode)
96-
.NotEmpty(address.Country))
127+
.NotEmpty(Items)
128+
.Nested(Items, item => Rules.Set<OrderItem>()
129+
.WithPropertyPrefix("item")
130+
.NotEmpty(item.ProductId)
131+
.GreaterThan(item.Quantity, 0)
132+
.GreaterThanOrEqual(item.Price, 0))
97133
.HasProblems(out problems);
98134
}
99135
}
100136
```
101137

102-
Validações aninhadas (coleções)
138+
Validating structs (value objects)
103139
```csharp
104-
public class OrderColl : IValidable
140+
public readonly struct Price : IValidable
105141
{
106-
public List<Address>? Addresses { get; set; }
142+
public decimal Amount { get; }
143+
public string Currency { get; }
144+
145+
public Price(decimal amount, string currency)
146+
{
147+
Amount = amount;
148+
Currency = currency;
149+
}
107150

108151
public bool HasProblems(out Problems? problems)
109152
{
110-
return Rules.Set<OrderColl>()
111-
.Nested(Addresses, address => Rules.Set<Address>()
112-
.WithPropertyPrefix("address")
113-
.NotEmpty(address.Street)
114-
.NotEmpty(address.City)
115-
.NotEmpty(address.ZipCode)
116-
.NotEmpty(address.Country))
153+
return Rules.Set<Price>()
154+
.GreaterThanOrEqual(Amount, 0)
155+
.NotEmpty(Currency)
117156
.HasProblems(out problems);
118157
}
119158
}
159+
160+
// Replace property name with the argument name automatically
161+
var prices = new[] { new Price(-1, ""), new Price(10, "USD") };
162+
var set = Rules.Set().Validate((IEnumerable<Price>)prices);
120163
```
121164

122-
Integração com SmartProblems
123-
- Cada regra que falha adiciona um `Problem` via `Problems.InvalidParameter(...)` com metadados:
124-
- `rule`: nome da regra (`Rules.RuleProperty`).
125-
- `current`: valor atual (`Rules.CurrentValueProperty`).
126-
- `expected`: valor(es) esperado(s) (`Rules.ExpectedValueProperty`).
127-
- `pattern`: expressão regular utilizada (`Rules.PatternProperty`).
128-
- O `RuleSet` pode ser convertido implicitamente em `Problems?` ou consultado via `HasProblems(out var problems)`.
129-
130-
Nomes de propriedades e prefixos
131-
- `CallerArgumentExpression` captura o nome da propriedade automaticamente.
132-
- `WithPropertyPrefix("prefix")` permite remover o prefixo do nome ao encadear erros de objetos aninhados.
133-
- Coleções incluem índice no encadeamento de propriedade.
134-
135-
Boas práticas
136-
- Concentre a validação em uma função por modelo de entrada (único `RuleSet`).
137-
- Use `IValidable`/`ValidateFunc` para validar agregados e objetos aninhados.
138-
- Prefira mensagens em `R` para internacionalização e consistência.
139-
- Utilize `StringComparison` apropriado em regras de string.
140-
141-
Licença
142-
- Consulte a licença do repositório.
165+
Property names and prefixes
166+
- Property names are captured by `CallerArgumentExpression`, so refactors keep error paths accurate.
167+
- Use `WithPropertyPrefix("prefix")` to remove a known prefix from nested paths when chaining problems.
168+
- Collections automatically include an index (e.g., `Items[2].Quantity`).
169+
170+
SmartProblems integration
171+
- Every failing rule adds a `Problem` via `Problems.InvalidParameter(...)` with metadata like:
172+
- `rule` (`Rules.RuleProperty`): the rule name (e.g., `min`, `max`, `lessThan`).
173+
- `current` (`Rules.CurrentValueProperty`): the current value.
174+
- `expected` (`Rules.ExpectedValueProperty`): expected value(s), when applicable.
175+
- `pattern` (`Rules.PatternProperty`): regex used in `Matches/NotMatches`.
176+
- For dual-operand rules (`Both*`, comparisons), properties and values are attached for both operands.
177+
178+
Best practices
179+
- Centralize validation per request/DTO in a single function that returns `Problems?`.
180+
- Favor `IValidable`/`ValidateFunc` to validate aggregates and nested objects.
181+
- Prefer message templates from `R` for localization consistency.
182+
- Use explicit `StringComparison` for string rules.
183+
- Avoid throwing for validation flow—return `Problems` and let callers decide.
184+
185+
License
186+
- See repository license.

RoyalCode.SmartValidations.Tests/RuleSetRules/RuleSetTests.Mist.cs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,101 @@
22

33
public partial class RuleSetTests
44
{
5+
[Fact]
6+
public void Email_Invalid_ProducesProblem()
7+
{
8+
// Arrange
9+
string? email = "invalid";
10+
11+
// Act
12+
var set = Rules.Set().Email(email);
13+
14+
// Assert
15+
Assert.True(set.HasProblems(out var problems));
16+
var p = Assert.Single(problems!);
17+
Assert.Equal(nameof(email), p.Property);
18+
Assert.Equal(Rules.Email, p.Extensions![Rules.RuleProperty]);
19+
Assert.Equal(email, p.Extensions[Rules.CurrentValueProperty]);
20+
}
21+
22+
[Fact]
23+
public void Email_Valid_NoProblems()
24+
{
25+
// Arrange
26+
string? email = "user@example.com";
27+
28+
// Act
29+
var set = Rules.Set().Email(email);
30+
31+
// Assert
32+
Assert.False(set.HasProblems(out var problems));
33+
Assert.Null(problems);
34+
}
35+
36+
[Fact]
37+
public void Email_Null_ProducesProblem()
38+
{
39+
// Arrange
40+
string? email = null;
41+
42+
// Act
43+
var set = Rules.Set().Email(email);
44+
45+
// Assert
46+
Assert.True(set.HasProblems(out var problems));
47+
var p = Assert.Single(problems!);
48+
Assert.Equal(nameof(email), p.Property);
49+
Assert.Equal(Rules.Email, p.Extensions![Rules.RuleProperty]);
50+
Assert.Null(p.Extensions[Rules.CurrentValueProperty]);
51+
}
52+
53+
[Fact]
54+
public void Url_Invalid_ProducesProblem()
55+
{
56+
// Arrange
57+
string? url = "not-a-url";
58+
59+
// Act
60+
var set = Rules.Set().Url(url);
61+
62+
// Assert
63+
Assert.True(set.HasProblems(out var problems));
64+
var p = Assert.Single(problems!);
65+
Assert.Equal(nameof(url), p.Property);
66+
Assert.Equal(Rules.Url, p.Extensions![Rules.RuleProperty]);
67+
Assert.Equal(url, p.Extensions[Rules.CurrentValueProperty]);
68+
}
69+
70+
[Fact]
71+
public void Url_Valid_NoProblems()
72+
{
73+
// Arrange
74+
string? url = "https://example.com/path?q=1";
75+
76+
// Act
77+
var set = Rules.Set().Url(url);
78+
79+
// Assert
80+
Assert.False(set.HasProblems(out var problems));
81+
Assert.Null(problems);
82+
}
83+
84+
[Fact]
85+
public void Url_WithPropertyPrefix_RemovesPrefixFromProperty()
86+
{
87+
// Arrange
88+
var model = new { Url = "invalid" };
89+
90+
// Act
91+
var set = Rules.Set<object>()
92+
.WithPropertyPrefix("model")
93+
.Url(model.Url);
94+
95+
// Assert
96+
Assert.True(set.HasProblems(out var problems));
97+
var p = Assert.Single(problems!);
98+
Assert.Equal("Url", p.Property);
99+
Assert.Equal(Rules.Url, p.Extensions![Rules.RuleProperty]);
100+
Assert.Equal(model.Url, p.Extensions[Rules.CurrentValueProperty]);
101+
}
5102
}

RoyalCode.SmartValidations.Tests/ValidateTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ public void Validate_ClassWithStruct_WithProblems()
6868

6969
// Assert property names for each problem
7070
var problemList = problems.ToList();
71-
Assert.Contains(problemList, p => p.Property == nameof(Catalog.Products));
71+
Assert.Contains(problemList, p => p.Property == $"{nameof(Catalog.Products)}[0]");
7272
}
7373

7474
[Fact]
@@ -95,7 +95,7 @@ public void Validate_Nestes_ClassWithStruct_WithProblems()
9595

9696
// Assert property names for each problem
9797
var problemList = problems.ToList();
98-
var expectedPropertyName = $"{nameof(catalog)}.{nameof(Catalog.Products)}";
98+
var expectedPropertyName = $"{nameof(catalog)}.{nameof(Catalog.Products)}[0]";
9999
Assert.Contains(problemList, p => p.Property == expectedPropertyName);
100100
}
101101

0 commit comments

Comments
 (0)