diff --git a/RoyalCode.EnterprisePatterns/.docs/instructions-for-copilot.md b/RoyalCode.EnterprisePatterns/.docs/instructions-for-copilot.md deleted file mode 100644 index e69de29..0000000 diff --git a/RoyalCode.EnterprisePatterns/.docs/problems.ai-rules.md b/RoyalCode.EnterprisePatterns/.docs/problems.ai-rules.md new file mode 100644 index 0000000..d0d231e --- /dev/null +++ b/RoyalCode.EnterprisePatterns/.docs/problems.ai-rules.md @@ -0,0 +1,371 @@ +# SmartProblems — Regras para IA + +Regras operacionais para gerar código com SmartProblems em **qualquer projeto .NET**. +Contexto conceitual: [`problems.md`](problems.md). + +> **Verificado contra:** `RoyalCode.SmartProblems` **1.0.0-preview-7.0** — .NET 8 / 9 / 10. +> **Precedência das fontes:** documentação XML do pacote (no IDE) > este arquivo > `problems.md`. +> Com versão divergente, confirme a assinatura no IntelliSense antes de gerar. + +## 1. Antes da primeira linha: pacote e `using` + +Pacote e namespace **divergem**. Não deduza; consulte. + +| Tipo / membro | `using` | Pacote NuGet | +|---|---|---| +| `Problem`, `Problems`, `Result`, `Result` | `RoyalCode.SmartProblems` | `RoyalCode.SmartProblems` | +| `FindResult<>`, `Id<,>`, `FindCriterion`, `FindCriteria<>` | `RoyalCode.SmartProblems.Entities` | `RoyalCode.SmartProblems` | +| `TryFindAsync`, `TryFindByAsync`, `FindByCriteria`, `AddTo`, `SaveChanges`, `RemoveFromAsync` | `Microsoft.EntityFrameworkCore` | `RoyalCode.SmartProblems.EntityFramework` | +| `OkMatch`, `OkMatch`, `CreatedMatch`, `NoContentMatch` (tipos) | `RoyalCode.SmartProblems.HttpResults` | `RoyalCode.SmartProblems.ApiResults` | +| `.OkMatch()`, `.CreatedMatch()`, `.NoContentMatch()` (extensions) | `Microsoft.AspNetCore.Http` | `RoyalCode.SmartProblems.ApiResults` | +| `WithExceptionFilter` | `Microsoft.AspNetCore.Builder` | `RoyalCode.SmartProblems.ApiResults` | +| `ToActionResult` (MVC) | `Microsoft.AspNetCore.Mvc` | `RoyalCode.SmartProblems.ApiResults` | +| `ToResultAsync` | `System.Net.Http` | `RoyalCode.SmartProblems.Http` | +| `FailureTypeReader` | `RoyalCode.SmartProblems.Http` | `RoyalCode.SmartProblems.Http` | +| `ToProblemDetails(options)` | `RoyalCode.SmartProblems.Conversions` | `RoyalCode.SmartProblems.ProblemDetails` | +| `ProblemDetailsOptions`, `ProblemDetailsDescription` | `RoyalCode.SmartProblems.Descriptions` | `RoyalCode.SmartProblems.ProblemDetails` | +| `AddProblemDetailsDescriptions` | `Microsoft.Extensions.DependencyInjection` | `RoyalCode.SmartProblems.ProblemDetails` | +| `MapProblemDetailsDescriptionPage` | `Microsoft.AspNetCore.Builder` | `RoyalCode.SmartProblems.ProblemDetails` | +| `EnsureIsValid`, `ToProblems`, `HasProblems` | `FluentValidation` | `RoyalCode.SmartProblems.FluentValidation` | + +Regras de namespace: tipos `OkMatch`/`CreatedMatch`/`NoContentMatch` usam `RoyalCode.SmartProblems.HttpResults`; +extensions `.OkMatch()`/`.CreatedMatch()`/`.NoContentMatch()` usam `Microsoft.AspNetCore.Http`; +`ToResultAsync` usa `System.Net.Http`; `ToProblemDetails` usa `RoyalCode.SmartProblems.Conversions`. + +## 2. Regras invioláveis + +1. **Nunca lance exceção para falha esperada.** Validação, regra de negócio e "não encontrado" retornam + `Result` / `Result` / `FindResult`. Exceção é só para o inesperado. +2. **Nunca use `default(Result)` nem `new Result()`.** Inicialize por valor, `Problem`, `Problems` ou `Result.Ok()`. +3. **`Problems.InternalError` inverte `property` e `typeId`.** Sempre passe `property:` nomeado. +4. **Cada `out var` precisa de nome único no escopo** (CS0128): `inputProblems`, `validationProblems`. +5. **Em `CollectAsync`/`MapAsync`/`ContinueAsync`/`MatchAsync` com `TParam`**, a ordem é + `(param, delegate, ct)`. O `CancellationToken` é sempre o último parâmetro do método. + Nunca `(param, ct, delegate)`. +6. **`FindCriteria.By(selector, value)`**: o seletor deve ser membro **direto** do parâmetro + (`c => c.Name`), e o valor deve ser o valor cru — nunca um `Id<,>` (use `id.Value`). +7. **Todo `typeId` customizado precisa de `ProblemDetailsDescription` registrada** antes de expor a API. +8. **Não use `try/catch` nem exception filter para validação, regra de domínio ou 404.** + +## 3. Categorias → HTTP status + +| Categoria | Status | Uso | +|---|---|---| +| `InvalidParameter` | 400 | entrada inválida do cliente (formato, range, obrigatório) | +| `ValidationFailed` | 422 | entrada sintaticamente válida, regra de domínio violada | +| `NotAllowed` | 403 | autorização, política, janela de operação | +| `InvalidState` | 409 | conflito de estado ou transição inválida | +| `NotFound` | 404 | recurso inexistente | +| `InternalServerError` | 500 | erro inesperado; nunca para erro de domínio | +| `CustomProblem` | conforme a descrição | erro de domínio fora das categorias; exige `typeId` | + +## 4. Assinaturas (cheat-sheet) + +Fábricas de `Problem`: + +```csharp +Problems.InvalidParameter(string detail, string? property = null, string? typeId = null); +Problems.ValidationFailed(string detail, string? property = null, string? typeId = null); +Problems.NotAllowed (string detail, string? property = null, string? typeId = null); +Problems.InvalidState (string detail, string? property = null, string? typeId = null); +Problems.NotFound (string detail, string? property = null, string? typeId = null); +Problems.InternalError (string? detail, string? typeId = null, string? property = null); // invertido! +Problems.InternalError (Exception? exception = null); +Problems.Custom (string detail, string typeId, string? property = null); +``` + +`Problem` / `Problems`: + +```csharp +Problem With(string key, object? value); // encadeável +Problem With(string key, TEnum value) where TEnum : Enum; +Problem ChainProperty(string? parentProperty); // "User" + "name" => "User.name" +Problem ChainProperty(string? parentProperty, int index); // => "User[0].name" +Result AsResult(); Result AsResult(); +InvalidOperationException ToException(); +InvalidOperationException ToException(string messagePattern, string separator = "\n"); +Problems operator +(Problem a, Problem b); // agrega +// implícitos: Problem -> Problems; Problem/Problems -> Result +``` + +`Result` — acesso e composição: + +```csharp +bool IsSuccess; bool IsFailure; +bool HasValue([NotNullWhen(true)] out T? value); +bool HasProblems([NotNullWhen(true)] out Problems? problems); +bool HasProblemsOrGetValue(out Problems? problems, out T? value); // um teste só +bool HasValueOrGetProblems(out T? value, out Problems? problems); +void EnsureHasValue(out T value); // LANÇA se IsFailure + +Result Map(Func map); +Result Map(Func> map); +Result Map(TParam param, Func map); +Result Continue(Action action); +Result Continue(Func action); +Result Continue(TParam param, Func action); +TResult Match(Func onSuccess, Func onFailure); + +// MatchAsync(param, onSuccess, onFailure, ct = default) +// onSuccess: (value, param, ct) => Task +// onFailure: (problems, param, ct) => Task | (problems, ct) => Task | (problems, param) => TResult +``` + +`Result` (sem valor): `Result.Ok()`, `IsSuccess`, `HasProblems(out problems)`. +Conversões implícitas: `T`, `Problem`, `Problems`, `Exception` e `FindResult` → `Result`; +`Result` → `Result`. + +`FindResult` e `FindResult`: + +```csharp +bool Found; +bool NotFound(out Problem? problem); // categoria NotFound (404) +bool HasInvalidParameter(out Problem? problem, string? parameterName = null); // 400 +Result ToResult(); +Result ToResult(string parameterName); // falha vira InvalidParameter +Result Collect(Action receiver); +Result Continue(Func receiver); +Result Map(Func receiver); +// + CollectAsync / ContinueAsync / MapAsync, todos com (param, delegate, ct) + +static FindResult Problem(string byName, string propertyName, object? propertyValue); +static FindResult Problem(ReadOnlySpan criteria); // multi-critério +``` + +`FindCriterion` e `Id<,>`: + +```csharp +new FindCriterion(string propertyName, object? value, string? byName = null); // ArgumentException se propertyName vazio +Id id = rawValue; // conversão implícita +TId value = id.Value; +``` + +Entity Framework: + +```csharp +Task> TryFindAsync(this DbContext db, Id id, CancellationToken ct = default); +Task> TryFindAsync(this DbSet set, TId id, CancellationToken ct = default); +Task> TryFindAsync(this DbSet set, Id id, CancellationToken ct = default); + +Task> TryFindByAsync(this DbContext db, Expression> filter, CancellationToken ct = default); +Task> TryFindByAsync(this DbContext db, Expression> filter, string byName, string propertyName, object? propertyValue, CancellationToken ct = default); +Task> TryFindByAsync(this DbContext db, Expression> propertySelector, TValue filterValue, CancellationToken ct = default); +// as mesmas três sobrecargas existem sobre DbSet + +FindCriteria FindByCriteria(this DbContext db); +FindCriteria FindByCriteria(this DbSet set); +FindCriteria FindByCriteria(this IQueryable query); // após Include/AsNoTracking + +// FindCriteria — imutável: reatribua a cada By +FindCriteria By(Expression> selector, TValue value); +FindCriteria By(Expression> selector, TValue value, string byName); +FindCriteria By(Expression> filter, string byName, string propertyName, object? value); +Task> TryFindAsync(CancellationToken ct = default); + +Result AddTo(this Result result, DbContext context); +ValueTask> AddToAsync(this Result result, DbContext context, CancellationToken ct = default); +Task> AddToAsync(this Task> result, DbContext context, CancellationToken ct = default); +Task> AddToAsync(this ValueTask> result, DbContext context, CancellationToken ct = default); + +Result SaveChanges(this Result result, DbContext context); +ValueTask SaveChangesAsync(this Result result, DbContext context, CancellationToken ct = default); +Task SaveChangesAsync(this Task result, DbContext context, CancellationToken ct = default); +Result SaveChanges(this Result result, DbContext context); +ValueTask> SaveChangesAsync(this Result result, DbContext context, CancellationToken ct = default); +Task> SaveChangesAsync(this Task> result, DbContext context, CancellationToken ct = default); + +Task> RemoveFromAsync(this Task> task, DbContext context, CancellationToken ct); +``` + +API e cliente HTTP: + +```csharp +OkMatch OkMatch(this Result result); +CreatedMatch CreatedMatch(this Result result, Func createdPathFunction); +CreatedMatch CreatedMatch(this Result result, string createdPath, bool formatPathWithValue = false); +// implícitos: Result -> OkMatch e NoContentMatch; +// Result, FindResult, T, Problem, Problems -> OkMatch +// => um handler pode retornar FindResult direto como OkMatch, sem ToResult() + +Task ToResultAsync(this HttpResponseMessage response, CancellationToken ct = default); +Task> ToResultAsync(this HttpResponseMessage response, JsonSerializerOptions? options = null, CancellationToken ct = default); +Task> ToResultAsync(this HttpResponseMessage response, JsonTypeInfo jsonTypeInfo, CancellationToken ct = default); +``` + +FluentValidation: + +```csharp +Result EnsureIsValid(this AbstractValidator validator, T model); +Task> EnsureIsValidAsync(this AbstractValidator validator, T model); +bool HasProblems(this AbstractValidator validator, T model, out Problems? problems); +bool HasProblems(this ValidationResult result, out Problems? problems); +Problems ToProblems(this IList errors); +``` + +## 5. Receitas canônicas + +Serviço de domínio: + +```csharp +public async Task> ConfirmAsync(int id, ConfirmRequest request, CancellationToken ct = default) +{ + if (request.HasProblems(out var requestProblems)) + return requestProblems; // 400 + + var find = await db.Set().TryFindAsync(id, ct); + if (find.NotFound(out var notFound)) + return notFound; // 404 + + var order = find.Entity; + if (order.IsShipped) + return Problems.InvalidState("Order is already shipped") // 409 + .With("orderId", id); + + order.Confirm(); + await db.SaveChangesAsync(ct); + return order; +} +``` + +Criação com EF helpers: + +```csharp +return await Product.Create(command) + .AddTo(db) + .SaveChangesAsync(db, ct); +``` + +Criação com etapa anterior assíncrona: + +```csharp +return await CreateProductAsync(command, ct) + .AddToAsync(db, ct) + .SaveChangesAsync(db, ct); +``` + +Remoção com EF helpers: + +```csharp +return await db.Set() + .TryFindByAsync(p => p.Id == id, ct) + .RemoveFromAsync(db, ct) + .SaveChangesAsync(db, ct); +``` + +Busca composta (dois ou mais critérios, chave composta, filtro condicional): + +```csharp +var criteria = db.FindByCriteria() + .By(c => c.StateId, stateId.Value); // Id<,> => .Value + +if (!string.IsNullOrWhiteSpace(name)) + criteria = criteria.By(c => c.Name, name); // imutável: reatribua + +var find = await criteria.TryFindAsync(ct); +// Detail: "The record of 'City' with StateId '42', Name 'Blumenau' was not found" +// Extensions: { entity: "City", StateId: 42, Name: "Blumenau" } +``` + +Critério que não é igualdade simples (`StartsWith`, range, `OR`, navegação): + +```csharp +criteria = criteria.By(c => c.Name.StartsWith(prefix), byName: "Name", propertyName: "name", value: prefix); +``` + +Minimal API: + +```csharp +var group = app.MapGroup("/api").WithExceptionFilter(); // só exceptions inesperadas + +group.MapGet("/orders/{id:int}", async (int id, OrderService svc, CancellationToken ct) + => await svc.GetAsync(id, ct)); // Task> + +group.MapPost("/orders", async (CreateOrder cmd, OrderService svc, CancellationToken ct) + => (await svc.CreateAsync(cmd, ct)).CreatedMatch(o => $"/api/orders/{o.Id}")); + +group.MapDelete("/orders/{id:int}", async (int id, OrderService svc, CancellationToken ct) + => await svc.DeleteAsync(id, ct)); // Task +``` + +Cliente HTTP: + +```csharp +var response = await http.GetAsync("/users/123", ct); +var result = await response.ToResultAsync(ct: ct); + +if (result.HasProblemsOrGetValue(out var problems, out var user)) + return problems; +return user; +``` + +Problema customizado (RFC 9457): + +```csharp +builder.Services.AddProblemDetailsDescriptions(options => +{ + options.BaseAddress = "https://api.exemplo.com/problems"; + options.Descriptor.Add(new ProblemDetailsDescription( + typeId: "order-on-hold", + title: "Order on hold", + description: "The order cannot move forward while risk analysis is pending.", + status: HttpStatusCode.Conflict)); +}); + +return Problems.Custom("Order is on hold due to risk analysis", "order-on-hold"); +``` + +## 6. Anti-padrões + +```csharp +// ❌ default: sucesso com valor nulo // ✅ construa explicitamente +Result r = default; Result r = order; + +// ❌ typeId = "userId" silenciosamente // ✅ nomeie o parâmetro +Problems.InternalError("Falha", "userId"); Problems.InternalError("Falha", property: "userId"); + +// ❌ CS0128 // ✅ nomes distintos +if (a.HasProblems(out var problems)) ... if (a.HasProblems(out var aProblems)) ... +if (b.HasProblems(out var problems)) ... if (b.HasProblems(out var bProblems)) ... + +// ❌ ArgumentException: membro indireto // ✅ membro direto, ou sobrecarga de predicado +criteria.By(c => c.State.Name, "SC"); criteria.By(c => c.State.Name == "SC", "State", "stateName", "SC"); + +// ❌ compila e lança em runtime // ✅ valor cru +criteria.By(c => c.StateId, stateId); criteria.By(c => c.StateId, stateId.Value); + +// ❌ ct antes do delegate // ✅ ct por último +result.MapAsync(param, ct, fn); result.MapAsync(param, fn, ct); + +// ❌ exceção para fluxo esperado // ✅ problema tipado +throw new NotFoundException(id); return Problems.NotFound("Order not found", "orderId"); + +// ❌ InvalidParameter para regra de domínio // ✅ 422 +Problems.InvalidParameter("Total negativo"); Problems.ValidationFailed("Total negativo", "total"); +``` + +Regras adicionais: + +- Use `EnsureHasValue` somente depois de tratar falhas. Não use `EnsureHasValue` para validar `default(Result)`. +- Use `TryFindAsync(id)` para chave primária e considere o change tracker. +- Use `TryFindByAsync(predicado)` ou `FindByCriteria(...).TryFindAsync(ct)` quando a busca deve consultar o banco. +- Use `AddTo(db).SaveChangesAsync(db, ct)` para `Result` já materializado. +- Use `AddToAsync(db, ct).SaveChangesAsync(db, ct)` apenas quando a etapa anterior é `Task>` + ou `ValueTask>`. +- Use `RemoveFromAsync(db, ct)` somente sobre `Task>`. +- Espere mensagem multi-campo automática em `TryFindByAsync(predicado)` apenas para `&&` de igualdades (`==`) + com membro direto da entidade. +- Para `!=`, `>`, `<`, `||`, `e.State.Name` e `e.A == e.B`, use `FindByCriteria` ou informe + `byName`, `propertyName` e `value`. +- Capture valores de getters antes do predicado quando houver efeito colateral: `var name = request.Name;`. + +## 7. Checklist antes de entregar o código + +- [ ] `using` e `PackageReference` conferidos na tabela da §1. +- [ ] Nenhum `throw` em fluxo esperado; nenhum `default(Result)`. +- [ ] `CancellationToken` como último parâmetro, em métodos e em delegates `Async`. +- [ ] `property:` nomeado em `Problems.InternalError`. +- [ ] `FindByCriteria` quando há dois ou mais critérios; `By` reatribuído em filtro condicional. +- [ ] `AddTo`/`SaveChanges`/`RemoveFromAsync` usados somente em fluxos `Result`/`FindResult`. +- [ ] `Id<,>` convertido com `.Value` ao entrar em `By`. +- [ ] Todo `typeId` customizado tem `ProblemDetailsDescription` registrada. +- [ ] Categoria coerente com o status esperado (§3). diff --git a/RoyalCode.EnterprisePatterns/.docs/problems.md b/RoyalCode.EnterprisePatterns/.docs/problems.md index b07fba6..6aca8ad 100644 --- a/RoyalCode.EnterprisePatterns/.docs/problems.md +++ b/RoyalCode.EnterprisePatterns/.docs/problems.md @@ -5,6 +5,22 @@ Serve também como referência para ferramentas de IA (ex.: GitHub Copilot) comp Projetos alvo: .NET 8, .NET 9 e .NET 10. +> **Para IA/agentes:** se você precisa apenas gerar código correto, use +> [`problems.ai-rules.md`](problems.ai-rules.md) — imperativo, autocontido, com tabela de pacotes, +> cheat-sheet de assinaturas e anti-padrões. Este arquivo é o guia longo, com o "porquê". +> +> **Verificado contra:** `RoyalCode.SmartProblems` **1.0.0-preview-7.0** (net8.0 / net9.0 / net10.0). +> Ao alterar a API pública, atualize esta linha junto com o exemplo afetado. Se a versão instalada no +> seu projeto for outra, a documentação XML do pacote é a fonte da verdade — este arquivo é o guia de +> uso e de padrões. + +Nota para IA e IDEs: este arquivo cobre **quando** e **por que** usar cada API, além das armadilhas que +o IntelliSense não revela (§8). Para confirmar sobrecargas, genéricos, retorno exato e ordem de parâmetros, +consulte a documentação XML das bibliotecas no pacote/IDE — especialmente em `Result`, `Result`, +`FindResult`, `FindResult`, `FindCriteria` e `AsyncResultExtensions`. +Antes de escrever a primeira linha de código, resolva pacote e `using` pela tabela da §1.1: os nomes de +pacote e de namespace **divergem** em vários casos e não são dedutíveis. + ## 1. Introdução SmartProblems padroniza o tratamento de resultados e erros de operações em .NET, evitando exceções para fluxo normal e tornando o código previsível e composable. @@ -17,6 +33,68 @@ Conceitos principais: - Conversões para `ProblemDetails` (RFC 9457) para uso em APIs. - Extensões para Entity Framework (métodos `TryFind*`). +### 1.1 Pacotes, namespaces e `using` + +Resolva isto **antes** de gerar código. O nome do pacote NuGet e o nome do namespace divergem em +vários casos — as linhas marcadas com ⚠️ não são dedutíveis a partir do tipo. + +| Tipo / membro | `using` (namespace) | Pacote NuGet | +|---|---|---| +| `Problem`, `Problems`, `Result`, `Result` | `RoyalCode.SmartProblems` | `RoyalCode.SmartProblems` | +| `FindResult<>`, `Id<,>`, `FindCriterion`, `FindCriteria<>`, `DisplayNames` | `RoyalCode.SmartProblems.Entities` | `RoyalCode.SmartProblems` | +| `TryFindAsync`, `TryFindByAsync`, `FindByCriteria`, `AddTo`, `SaveChanges`, `RemoveFromAsync` | ⚠️ `Microsoft.EntityFrameworkCore` | `RoyalCode.SmartProblems.EntityFramework` | +| `OkMatch`, `OkMatch`, `CreatedMatch`, `NoContentMatch` (tipos) | ⚠️ `RoyalCode.SmartProblems.HttpResults` | ⚠️ `RoyalCode.SmartProblems.ApiResults` | +| `.OkMatch()`, `.CreatedMatch()`, `.NoContentMatch()` (extensions) | ⚠️ `Microsoft.AspNetCore.Http` | ⚠️ `RoyalCode.SmartProblems.ApiResults` | +| `WithExceptionFilter` | ⚠️ `Microsoft.AspNetCore.Builder` | `RoyalCode.SmartProblems.ApiResults` | +| `ToActionResult` e afins (MVC) | ⚠️ `Microsoft.AspNetCore.Mvc` | `RoyalCode.SmartProblems.ApiResults` | +| `ToResultAsync`, `FailureTypeReader` | ⚠️ `System.Net.Http` / `RoyalCode.SmartProblems.Http` | `RoyalCode.SmartProblems.Http` | +| `ToProblemDetails(options)`, `ProblemDetailsExtended` | `RoyalCode.SmartProblems.Conversions` | ⚠️ `RoyalCode.SmartProblems.ProblemDetails` | +| `ProblemDetailsOptions`, `ProblemDetailsDescription` | `RoyalCode.SmartProblems.Descriptions` | `RoyalCode.SmartProblems.ProblemDetails` | +| `AddProblemDetailsDescriptions` | ⚠️ `Microsoft.Extensions.DependencyInjection` | `RoyalCode.SmartProblems.ProblemDetails` | +| `MapProblemDetailsDescriptionPage` | ⚠️ `Microsoft.AspNetCore.Builder` | `RoyalCode.SmartProblems.ProblemDetails` | +| `EnsureIsValid`, `ToProblems`, `HasProblems` (validator) | ⚠️ `FluentValidation` | `RoyalCode.SmartProblems.FluentValidation` | + +Pontos que causam erro de compilação ou pacote faltante com mais frequência: + +- Os tipos `OkMatch` e família estão no pacote **`ApiResults`**, mas no namespace **`HttpResults`**. +- Os métodos `.OkMatch()`, `.CreatedMatch()` e `.NoContentMatch()` são extensions em **`Microsoft.AspNetCore.Http`**. +- `ToResultAsync` é injetado em **`System.Net.Http`** (namespace já implícito com `ImplicitUsings`), + então "compila sem `using`" — mas exige o pacote `RoyalCode.SmartProblems.Http`. Os tipos auxiliares + (`FailureTypeReader`) ficam em `RoyalCode.SmartProblems.Http`. +- `ToProblemDetails` está no namespace `...Conversions`, porém é entregue pelo pacote + **`RoyalCode.SmartProblems.ProblemDetails`** (o pacote `...Conversions` traz a serialização e + `ProblemDetailsExtended`, não a conversão a partir de `Problems`). +- As extensões de EF e de ASP.NET Core usam os namespaces da Microsoft de propósito: instalado o pacote, + os métodos aparecem sem `using` novo. + +`using` canônicos por cenário: + +```csharp +// Serviço de domínio (sem EF, sem HTTP) +using RoyalCode.SmartProblems; + +// Serviço com EF +using Microsoft.EntityFrameworkCore; // TryFindAsync, FindByCriteria, AddTo, SaveChanges +using RoyalCode.SmartProblems; // Result, Problems +using RoyalCode.SmartProblems.Entities; // FindResult, Id, FindCriterion + +// Minimal API +using Microsoft.AspNetCore.Http; // OkMatch/CreatedMatch/NoContentMatch extension methods +using Microsoft.AspNetCore.Builder; // WithExceptionFilter, MapProblemDetailsDescriptionPage +using Microsoft.Extensions.DependencyInjection; // AddProblemDetailsDescriptions +using RoyalCode.SmartProblems; +using RoyalCode.SmartProblems.HttpResults; // OkMatch, CreatedMatch, NoContentMatch +using RoyalCode.SmartProblems.Descriptions; // ProblemDetailsOptions, ProblemDetailsDescription + +// Cliente HTTP +using RoyalCode.SmartProblems; +using RoyalCode.SmartProblems.Http; // FailureTypeReader (ToResultAsync já vem de System.Net.Http) + +// Validação com FluentValidation +using FluentValidation; // EnsureIsValid, ToProblems +using RoyalCode.SmartProblems; +``` + ## 2. Funcionalidades Principais - Modelagem de erros @@ -40,14 +118,17 @@ Conceitos principais: - Composição: `Collect`, `Continue`, `Map` (+ `Async`). - Conversão: `ToResult([parameterName])`. - Fábrica: `FindResult.Problem(byName, propertyName, propertyValue)`. + - Fábrica multi-critério: `FindResult.Problem(ReadOnlySpan)`. - Conversão para `ProblemDetails` - - `RoyalCode.SmartProblems.Conversions`: `Problems.ToProblemDetails(options)`. + - `Problems.ToProblemDetails(options)`: namespace `RoyalCode.SmartProblems.Conversions`, + entregue pelo pacote `RoyalCode.SmartProblems.ProblemDetails` (ver §1.1). - `ProblemDetailsExtended` agrega múltiplos problemas (`errors`, `not_found`, `inner_details`). - - Personalização via `ProblemDetailsOptions` e `ProblemDetailsDescriptor`. + - Personalização via `ProblemDetailsOptions`, `ProblemDetailsDescriptor`, `ProblemDetailsDescription` e arquivos JSON. + - Página HTML de catálogo via `MapProblemDetailsDescriptionPage()`. - Integrações - - Entity Framework: `SmartProblemsEFExtensions` com `TryFindAsync`/`TryFindByAsync` e `FindResult`. + - Entity Framework: `SmartProblemsEFExtensions` com `TryFindAsync`/`TryFindByAsync`, `FindByCriteria` e `FindResult`. - FluentValidation: `ValidationsExtensions` (`ToProblems`, `HasProblems`, `EnsureIsValid`, `Validate`/`ValidateAsync`). - HTTP/ASP.NET: utilitários para converter para `ProblemDetails` e resultados de API. @@ -116,6 +197,32 @@ var p500Default = Problems.InternalError(); // usa mensagem padrão configurada var pCustom = Problems.Custom("Order on hold", typeId: "order-on-hold", property: "status"); ``` +⚠️ **`InternalError` inverte a ordem de `property` e `typeId`** em relação às demais fábricas. +Não é um erro de compilação — é um erro silencioso que altera o `type` do `ProblemDetails`: + +```csharp +// Demais fábricas: (detail, property, typeId) +Problems.InvalidParameter(string detail, string? property = null, string? typeId = null); +Problems.ValidationFailed(string detail, string? property = null, string? typeId = null); +Problems.NotAllowed (string detail, string? property = null, string? typeId = null); +Problems.InvalidState (string detail, string? property = null, string? typeId = null); +Problems.NotFound (string detail, string? property = null, string? typeId = null); + +// InternalError: (detail, typeId, property) <-- invertido! +Problems.InternalError (string? detail, string? typeId = null, string? property = null); + +// Custom exige typeId, na segunda posição +Problems.Custom (string detail, string typeId, string? property = null); +``` + +```csharp +// ❌ define typeId = "userId" sem querer +Problems.InternalError("Falha ao gravar", "userId"); + +// ✅ sempre use argumento nomeado em InternalError +Problems.InternalError("Falha ao gravar", property: "userId"); +``` + Extensões e propriedades encadeadas: ```csharp @@ -123,6 +230,15 @@ p400.With("attempt", 1).ChainProperty("User", 0); // User[0].name p422.With("policy", "minimum-total"); ``` +### Convertendo Problems para exceção + +Quando for necessário atravessar uma fronteira que ainda espera exceptions, converta a coleção de problemas em exceção com `ToException(...)`: + +```csharp +var ex = (p400 + p422).ToException("Validation errors: {0}"); +throw ex; +``` + ### Validando classes de forma padronizada Para validar as propriedades de uma classe pode ser criado um método HasProblems que retorna os problemas encontrados: @@ -159,7 +275,7 @@ public class User DICA: Use a biblioteca RoyalCode.SmartValidation para validação fluente, com RuleSet, e integrada com Problems e Results. -### Custom e RFC 9457 (type) +### Custom, TypeId e RFC 9457 (type) Em `ProblemDetails` (RFC 9457), o campo `type` deve ser um identificador do tipo de problema (preferencialmente uma URI). Recomendações atualizadas do RFC 9457: @@ -170,29 +286,106 @@ Recomendações atualizadas do RFC 9457: - Evite `about:blank` para problemas customizados; descreva tipos próprios com documentação. Impacto do `Problems.Custom(detail, typeId, property)` na conversão: -- O `typeId` é mapeado para `ProblemDetails.Type`. -- Se configurado em `ProblemDetailsOptions.Descriptor`, define `title` e `status` consistentes para o tipo. -- Sem descrição, será usado um tipo agregado ou padrão conforme contexto. +- O `typeId` do `Problem` é usado para localizar uma `ProblemDetailsDescription` em `ProblemDetailsOptions.Descriptor`. +- Se a descrição tem `Type` explícito, esse valor vira `ProblemDetails.Type`. +- Se a descrição não tem `Type`, a URI é gerada por `BaseAddress + TypeComplement + TypeId`. +- `ProblemDetails.Title` e `ProblemDetails.Status` vêm da descrição localizada. +- `ProblemDetails.Detail` vem do `detail` do problema ocorrido; já `ProblemDetailsDescription.Description` serve para documentação/catálogo do tipo. +- Sem descrição específica para o `typeId`, `CustomProblem` cai no tipo genérico `problem-occurred`, com status padrão 400. Para contrato de API estável, sempre registre uma descrição para cada `typeId` customizado. +- Quando há vários problemas customizados na mesma resposta, o tipo externo é agregado (`aggregate-problems-details`) e os problemas específicos aparecem nos detalhes agregados. Exemplo de configuração do tipo no `ProblemDetailsOptions`: ```csharp +using System.Net; +using RoyalCode.SmartProblems.Descriptions; + var options = new ProblemDetailsOptions(); options.Descriptor.Add(new ProblemDetailsDescription( typeId: "order-on-hold", title: "Order on hold", - description: "Business rule violation", - statusCode: System.Net.HttpStatusCode.Conflict)); + description: "The order cannot move forward while risk analysis is pending.", + status: HttpStatusCode.Conflict)); Problems problem = Problems.Custom("Order is on hold due to risk analysis", "order-on-hold"); -var pd = problem.ToProblemDetails(options); // Type será "tag:problemdetails/.problems#order-on-hold" ou uma URI absoluta se configurada +var pd = problem.ToProblemDetails(options); +// Type: "tag:problemdetails/.problems#order-on-hold" +// Title: "Order on hold" +// Status: 409 +// Detail: "Order is on hold due to risk analysis" ``` -Converter coleção de problemas para exceção: +Quando o contrato público exige uma URI absoluta, informe o `type` explicitamente: ```csharp -var ex = (p400 + p422).ToException("Validation errors: {0}"); -throw ex; +options.Descriptor.Add(new ProblemDetailsDescription( + typeId: "order-on-hold", + type: "https://api.exemplo.com/problems/order-on-hold", + title: "Order on hold", + description: "The order cannot move forward while risk analysis is pending.", + status: HttpStatusCode.Conflict)); ``` +### Catálogo e página de descrição dos problemas + +Use o pacote `RoyalCode.SmartProblems.ProblemDetails`. Registre as descrições conhecidas pela aplicação com `AddProblemDetailsDescriptions`; elas alimentam a conversão para `ProblemDetails` e a página HTML de documentação. + +```csharp +using System.Net; +using RoyalCode.SmartProblems.Descriptions; + +builder.Services.AddProblemDetailsDescriptions(options => +{ + options.BaseAddress = "https://api.exemplo.com/problems"; + options.TypeComplement = "/"; + + options.Descriptor.Add(new ProblemDetailsDescription( + typeId: "order-on-hold", + title: "Order on hold", + description: "The order cannot move forward while risk analysis is pending.", + status: HttpStatusCode.Conflict)); +}); +``` + +Também é possível carregar descrições por arquivo JSON: + +```csharp +builder.Services.AddProblemDetailsDescriptions(options => +{ + options.DescriptionFiles = ["problem-details.json"]; +}); +``` + +Formato recomendado do arquivo: +```json +[ + { + "typeId": "order-on-hold", + "type": "https://api.exemplo.com/problems/order-on-hold", + "title": "Order on hold", + "description": "The order cannot move forward while risk analysis is pending.", + "status": 409 + } +] +``` + +Depois de registrar as descrições, publique a página de catálogo: + +```csharp +var app = builder.Build(); + +app.MapProblemDetailsDescriptionPage(); // GET /.problems +// ou: +app.MapProblemDetailsDescriptionPage("/docs/problems"); +``` + +A página é opt-in e lista os tipos conhecidos pelo `ProblemDetailsDescriptor`: categorias padrão, descrições carregadas em JSON e descrições adicionadas em código. Ela mostra `TypeId`, URI final do `type`, título, status e descrição. Se uma descrição não tiver `Type` explícito, a página resolve a URI com `BaseAddress + TypeComplement + TypeId`; quando `BaseAddress` estiver no default da biblioteca, a própria rota da página é usada como base navegável. + +Regras para IA ao criar problemas customizados: +- Use `typeId` curto, estável e válido como parte relativa de URI, preferencialmente em kebab-case (`order-on-hold`, `payment-required`). +- Registre uma `ProblemDetailsDescription` para cada `typeId` customizado antes de expor a API. +- Use `type` explícito quando a documentação pública mora em uma URL canônica; use `BaseAddress`/`TypeComplement` quando a própria API publica o catálogo. +- Escreva `description` como documentação do tipo: quando ocorre, por que ocorre e o que o consumidor pode fazer. +- Não dependa do fallback `problem-occurred` para erros de domínio públicos. + ## 4. Exemplos de uso: Result Construção e verificação: @@ -209,7 +402,16 @@ Composição síncrona e assíncrona: var res = ok.Map(v => v.Length); // Result var next = ok.Continue(v => Result.Ok()); // Result -var async = await ok.MapAsync(v => Task.FromResult(v.Length)); +var async = await ok.MapAsync(static v => Task.FromResult(v.Length)); + +// Async com TParam: param, delegate, ct por último. +var saved = await ok.ContinueAsync( + repository, + static async (value, repo, token) => + { + await repo.SaveAsync(value, token); + }, + ct); // branch explícito: Match var outRes = ok.Match( @@ -222,24 +424,50 @@ var outResAsync = await ok.MatchAsync( problems => Task.FromResult(problems.AsResult())); ``` +Regra para sobrecargas `Async` com `TParam`: +- Quando o delegate retorna `Task` ou `Task`, ele recebe `CancellationToken` como último parâmetro. +- O `CancellationToken` público do método fica por último e tem default: `.MapAsync(param, static (..., token) => ..., ct)`. +- Não use a forma antiga `.MapAsync(param, ct, delegate)` ou `.ContinueAsync(param, ct, delegate)`. +- Delegates síncronos com `TParam` continuam sem `CancellationToken`. + +Exemplo de `MatchAsync` com `TParam` e `CancellationToken`: +```csharp +return await result.MatchAsync( + logger, + static (value, log, token) => + { + token.ThrowIfCancellationRequested(); + log.LogInformation("Operation succeeded"); + return Task.FromResult(value); + }, + static (problems, log, token) => + { + token.ThrowIfCancellationRequested(); + log.LogWarning("Operation failed with {Count} problems", problems.Count); + return Task.FromResult(string.Empty); + }, + ct); +``` + Casos de uso reais (serviços, handlers, repositórios): ```csharp // Result sem valor public readonly struct UserService { private readonly IUserRepository _repo; - private readonly IUserValidator _validator; + private readonly AbstractValidator _validator; // EnsureIsValid é extensão de AbstractValidator private readonly IUserPolicy _policy; public Result Create(UserInput input) { // validação de entrada - if (input.HasProblems(out var problems)) - return problems; // 400. + // Atenção: cada `out var` precisa de um nome único no mesmo escopo (CS0128). + if (input.HasProblems(out var inputProblems)) + return inputProblems; // 400. // regra de negócio - if (_validator.EnsureIsValid(input).HasProblems(out var problems)) - return problems; // 400/422 etc. + if (_validator.EnsureIsValid(input).HasProblems(out var validationProblems)) + return validationProblems; // 400/422 etc. // regra de negócio if (!_policy.CanCreate(input)) @@ -250,11 +478,11 @@ public readonly struct UserService return Result.Ok(); } - public Task DisableAsync(int id) + public async Task DisableAsync(int id) { - var findUser = _repo.FindByIdAsync(id); + var findUser = await _repo.FindByIdAsync(id); if (findUser.NotFound(out var problem)) - return Task.FromResult((Result)problem); // 404 + return problem; // 404 findUser.Entity.Disable(); @@ -297,35 +525,42 @@ r1 += r2; // combina problemas ## 5. Exemplos para Entidades (Id, FindResult, TryFindAsync) -A extensão de Entity Framework fornece métodos `TryFindAsync` e `TryFindByAsync` que retornam um `FindResult`. +A extensão de Entity Framework fornece métodos `TryFindAsync`, `TryFindByAsync` e `FindByCriteria` que retornam um `FindResult`. Esse tipo encapsula o resultado da busca: a entidade encontrada (`Entity`) ou um problema padronizado quando não encontrada. -- `TryFindAsync(DbContext, Id)` e `TryFindAsync(DbSet, Id)`: +- `TryFindAsync(DbContext, Id)`, `TryFindAsync(DbSet, TId)` e `TryFindAsync(DbSet, Id)`: - Quando a entidade não existe, gera um `Problem` com categoria `NotFound` (HTTP 404) e mensagem bem definida. - - Campos extras adicionados em `Extensions`: - - `id`: o valor do identificador usado na busca. - - `entity`: o nome da entidade (ex.: `TestEntity`). + - Ao receber `Id`, o valor usado na busca e no problema é `id.Value`, não o wrapper `Id`. + - Campos extras adicionados em `Extensions`: `id` e `entity`. -- `TryFindByAsync(DbContext/DbSet, Expression>)` e sobrecargas com nomes: - - Quando o filtro não encontra a entidade, gera `Problem` `NotFound` com detalhe incluindo o display name da entidade e o nome/valor do campo. - - Campos extras em `Extensions`: - - `` ou alias informado (ex.: `Name` ou `name`): valor usado no filtro. - - `entity`: nome da entidade. +- `TryFindByAsync(DbContext/DbSet, Expression>)`: + - Executa o filtro com `FirstOrDefaultAsync`. + - Se não encontrar, tenta gerar uma mensagem rica analisando o predicado. + - Só gera critérios automáticos para expressões `&&` compostas por igualdades (`==`) em que um lado é membro direto da entidade (`e => e.Name`) e o outro lado é valor constante/capturado. + - Qualquer expressão ambígua ou potencialmente enganosa degrada para a mensagem genérica `The record for 'Entity' was not found`. -Uso típico: +- `FindByCriteria()`: + - Use quando a busca tem dois ou mais critérios, chave composta, filtros condicionais, ou quando você quer informar os valores diretamente sem depender da análise automática da expressão. + - Pode começar em `DbContext`, `DbSet` ou `IQueryable` já customizado com `Include`, `AsNoTracking` etc. + - Cada chamada `By` retorna uma nova instância; para montar filtros condicionais, reatribua a variável. + +Uso típico por id: ```csharp -// Buscar por Id -Id id = 4; -var entry = await db.TestEntities.TryFindAsync(id); +Id id = 4; +var entry = await db.TestEntities.TryFindAsync(id, ct); + if (entry.NotFound(out var problem)) { // problem.Detail: "The record of 'The Entity for Tests' with id '4' was not found" // problem.Extensions: { id: 4, entity: "TestEntity" } - return problem; // como Result/Problems ou ProblemDetails + return problem; } +``` + +Uso por propriedade simples: +```csharp +var byName = await db.TestEntities.TryFindByAsync(e => e.Name == "Test4", ct); -// Buscar por propriedade -var byName = await db.TestEntities.TryFindByAsync(e => e.Name == "Test4"); if (byName.NotFound(out var problemByName)) { // problemByName.Detail: "The record of 'The Entity for Tests' with Name 'Test4' was not found" @@ -334,24 +569,164 @@ if (byName.NotFound(out var problemByName)) } ``` -Quando possui nomes customizados (ex.: título do campo, alias e valor), use a sobrecarga: +Busca composta recomendada com `FindByCriteria`: +```csharp +var city = await db.FindByCriteria() + .By(c => c.StateId, stateId) + .By(c => c.Name, name) + .TryFindAsync(ct); + +if (city.NotFound(out var problem)) +{ + // Detail: "The record of 'City' with StateId '42', Name 'Blumenau' was not found" + // Extensions: { entity: "City", StateId: 42, Name: "Blumenau" } + return problem; +} +``` + +Começando a partir de um `IQueryable` customizado: ```csharp -var entry2 = await db.TryFindByAsync(e => e.Name == "Test4", displayName: "Name", alias: "name", value: "Test4"); +var city = await db.Set() + .AsNoTracking() + .Include(c => c.State) + .FindByCriteria() + .By(c => c.StateId, stateId, "State") + .By(c => c.Name, name) + .TryFindAsync(ct); +``` + +Filtros condicionais: como `FindCriteria` é imutável, sempre reatribua: +```csharp +var criteria = db.FindByCriteria() + .By(c => c.StateId, stateId); + +if (!string.IsNullOrWhiteSpace(name)) + criteria = criteria.By(c => c.Name, name); + +var city = await criteria.TryFindAsync(ct); +``` + +Quando um critério precisa de lógica além de igualdade (`StartsWith`, range, `OR`), use a sobrecarga com predicado e dados explícitos para o problema: +```csharp +var city = await db.FindByCriteria() + .By(c => c.StateId, stateId) + .By(c => c.Name.StartsWith(prefix), byName: "Name", propertyName: "Name", value: prefix) + .TryFindAsync(ct); +``` + +Também é possível criar um problema multi-critério diretamente com `FindCriterion`: +```csharp +FindCriterion[] criteria = +[ + new("StateId", stateId, "State"), + new("Name", name) +]; + +FindResult notFound = FindResult.Problem(criteria); +``` + +Regras de `FindCriterion` e `FindResult.Problem(criteria)`: +- Critérios são listados na ordem recebida. +- Com um único critério, a mensagem mantém o formato legado de `Problem(byName, propertyName, value)`. +- Com múltiplos critérios, o detalhe lista todos: `State '42', Name 'Blumenau'`. +- `ByName` nulo ou em branco é resolvido por `DisplayNames`, respeitando `DisplayNameAttribute`. +- `FindCriterion` rejeita `propertyName` nulo/vazio; `default(FindCriterion)` é ignorado pela fábrica multi-critério. +- Se todos os critérios forem inválidos/ignorados, a mensagem cai para o `NotFound` genérico. +- Em `Extensions`, se a mesma `propertyName` aparecer mais de uma vez, a última vence. + +Sobre a análise automática de `TryFindByAsync(predicate)`: +```csharp +// Gera mensagem rica multi-critério: +await db.TryFindByAsync(c => c.StateId == stateId && c.Name == name, ct); + +// Também funciona com a igualdade invertida: +await db.TryFindByAsync(c => name == c.Name, ct); + +// Degrada para mensagem genérica: "!=" não afirma que a entidade tem aquele valor. +await db.TryFindByAsync(c => c.Name != "Blumenau", ct); + +// Degrada para genérica: OR não pode ser descrito como lista simples de critérios AND. +await db.TryFindByAsync(c => c.Name == "A" || c.Name == "B", ct); + +// Degrada para genérica: cadeia profunda ou comparação entre membros da entidade. +await db.TryFindByAsync(c => c.State.Name == "SC", ct); +await db.TryFindByAsync(s => s.Name == s.Code, ct); +``` + +A análise do valor nunca compila a expressão nem invoca métodos do predicado, então `track("x")` não é chamado uma segunda vez apenas para montar a mensagem. +Ela pode, porém, ler novamente getters de objetos capturados, como `request.Name`, para obter o valor usado no detalhe. Se o getter tiver efeito colateral ou valor variável, prefira `FindByCriteria().By(c => c.Name, request.Name)` e capture o valor uma vez antes da busca. + +Quando possui nomes customizados em uma chamada direta de `TryFindByAsync`, use a sobrecarga explícita: +```csharp +var entry2 = await db.TryFindByAsync( + e => e.Name == "Test4", + byName: "Name", + propertyName: "name", + propertyValue: "Test4", + ct); + if (entry2.NotFound(out var p)) { // p.Extensions: { name: "Test4", entity: "TestEntity" } } ``` +Persistência com helpers EF: + +- `AddTo`/`AddToAsync` adiciona a entidade ao `DbContext` somente quando o `Result` tem valor. +- `SaveChanges`/`SaveChangesAsync` chama `DbContext.SaveChanges` somente quando o `Result` está em sucesso. +- `RemoveFromAsync` existe para `Task>`; remove somente quando a entidade foi encontrada e retorna `Result`. +- Para `Result` já materializado, prefira `AddTo(db)` quando quiser encadear imediatamente com `SaveChangesAsync`. + +Criação: +```csharp +return await Product.Create(command) + .AddTo(db) + .SaveChangesAsync(db, ct); +``` + +Criação quando a etapa anterior já é assíncrona: +```csharp +return await CreateProductAsync(command, ct) + .AddToAsync(db, ct) + .SaveChangesAsync(db, ct); +``` + +Remoção: +```csharp +return await db.Products + .TryFindByAsync(p => p.Id == id, ct) + .RemoveFromAsync(db, ct) + .SaveChangesAsync(db, ct); +``` + Composição com `FindResult`: ```csharp -await entry.ContinueAsync(async entity => -{ - // prossiga com a entidade - return Result.Ok(); -}); +var result = await entry.ContinueAsync( + repository, + static async (entity, repo, token) => + { + await repo.SaveAsync(entity, token); + return Result.Ok(); + }, + ct); ``` +Com `CollectAsync` o mesmo padrão vale: `param`, delegate, `ct`. + +```csharp +var result = await entry.CollectAsync( + dto, + static async (entity, request, token) => + { + entity.Update(request.Name); + await Task.CompletedTask.WaitAsync(token); + }, + ct); +``` + +Não use a ordem antiga `.CollectAsync(dto, ct, static ...)`; o `CancellationToken` do método deve ficar por último. + Além de `NotFound`, o `FindResult` também suporta retornar `InvalidParameter` em cenários onde o identificador/parâmetro informado é inválido para a operação atual. Os métodos `HasInvalidParameter(out problem, parameterName)` e sobrecargas de `Continue/Map/ToResult(parameterName)` ajudam a padronizar essa resposta: ```csharp @@ -364,6 +739,7 @@ var res = entry.ToResult("id"); Os tipos `OkMatch`, `NoContentMatch` e `CreatedMatch` permitem mapear `Result`/`Result` para respostas HTTP padronizadas, convertendo automaticamente problemas em `ProblemDetails` (RFC 9457) quando necessário. Exemplos baseados em `MatchApi`: + ```csharp // POST: cria e retorna 201 com Location e corpo private static async Task> CreatePerson(PersonCreate create) @@ -420,6 +796,34 @@ Comportamento esperado (vide `MatchApiTests`): - Sucesso: 201/200/204 com Location e/ou corpo conforme tipo. - Falha: problemas convertidos para `ProblemDetails` com status coerente (404, 400, etc.). +### Filtro de exceções para Minimal API + +Use `WithExceptionFilter` na borda HTTP para transformar exceções inesperadas em `ProblemDetails` 500 padronizado. Prefira aplicar o filtro em grupos, para que todas as rotas do grupo compartilhem a mesma política: + +```csharp +var group = app.MapGroup("/api") + .WithExceptionFilter(); + +group.MapGet("/produto/{id:int}", GetProduto); +group.MapPost("/produto", CriarProduto); +``` + +Quando quiser registrar também falhas esperadas retornadas por `OkMatch`, `CreatedMatch` ou `NoContentMatch`, informe um `LogLevel`. O parâmetro `loggerType` define a categoria do logger usado pelo filtro: + +```csharp +var group = app.MapGroup("/api") + .WithExceptionFilter(LogLevel.Error, typeof(Program)); + +group.MapGet("/produto/{id:int}", GetProduto); +``` + +Regras para IA ao gerar Minimal APIs: +- Use `WithExceptionFilter()` em `MapGroup` quando várias rotas compartilham a mesma borda de API. +- Use o filtro para exceptions inesperadas, falhas de infraestrutura e erros não previstos. +- Não use `try/catch` nem exception filter para validação esperada, regra de domínio ou recurso não encontrado; retorne `Result`/`Problems` e converta com `OkMatch`, `CreatedMatch` ou `NoContentMatch`. +- O filtro sempre registra exceptions capturadas como `LogLevel.Error`. +- O parâmetro `logLevel` controla apenas o log de respostas de erro já modeladas como `MatchErrorResult`. + Boas práticas (RFC 9457): - Para `CreatedMatch`, forneça `Location` com URI absoluta ou relativa estável. - Títulos (`title`) claros e condizentes com o `type`; descrição (`detail`) objetiva. @@ -433,12 +837,12 @@ Métodos `HttpResultExtensions.ToResultAsync` desserializam respostas HTTP em `R Assinaturas principais: ```csharp -Task ToResultAsync(this HttpResponseMessage response, CancellationToken token = default); -Task> ToResultAsync(this HttpResponseMessage response, JsonSerializerOptions? options = null, CancellationToken token = default); +Task ToResultAsync(this HttpResponseMessage response, CancellationToken ct = default); +Task> ToResultAsync(this HttpResponseMessage response, JsonSerializerOptions? options = null, CancellationToken ct = default); Task> ToResultAsync(this HttpResponseMessage response, JsonTypeInfo jsonTypeInfo, CancellationToken ct = default); // Com FailureTypeReader para conteúdo de erro não-ProblemDetails -Task> ToResultAsync(this HttpResponseMessage response, FailureTypeReader? reader, JsonSerializerOptions? options = null, CancellationToken token = default); -Task> ToResultAsync(this HttpResponseMessage response, FailureTypeReader? reader, JsonTypeInfo jsonTypeInfo, CancellationToken ct = default); +Task> ToResultAsync(this HttpResponseMessage response, FailureTypeReader? failureTypeReader, JsonSerializerOptions? options = null, CancellationToken ct = default); +Task> ToResultAsync(this HttpResponseMessage response, FailureTypeReader? failureTypeReader, JsonTypeInfo jsonTypeInfo, CancellationToken ct = default); ``` Exemplos reais de consumo com `HttpClient`: @@ -509,7 +913,120 @@ Boas práticas (RFC 9457): - APIs devem retornar `application/problem+json` para falhas; clientes devem interpretar `type`, `title`, `status`, `detail`, `instance`. - Use `type`/`instance` URIs estáveis; evite conflitar extensões com campos reservados. -## 8. Boas Práticas +## 8. Erros comuns (❌ / ✅) + +Armadilhas que o IntelliSense não revela. Todas verificadas contra `1.0.0-preview-7.0`. + +### 8.1 `TryFindAsync` tem duas semânticas sob o mesmo nome + +`TryFindAsync(id)` usa `FindAsync` do EF: **consulta o change tracker primeiro** e, se a entidade já +estiver rastreada, retorna sem emitir SQL. `TryFindByAsync(predicado)` e `FindByCriteria()...TryFindAsync(ct)` +usam `FirstOrDefaultAsync`: **sempre** emitem SQL, e alterações ainda não salvas não afetam o filtro. + +```csharp +// ✅ chave primária: pode resolver pelo change tracker, sem ida ao banco +var byId = await db.Set().TryFindAsync(cityId, ct); + +// ⚠️ sempre emite SQL; não enxerga entidades adicionadas/alteradas e ainda não salvas +var byName = await db.FindByCriteria().By(c => c.Name, "Nova").TryFindAsync(ct); +``` + +Regra: busca por chave primária → `TryFindAsync(id)`. Busca por outros campos → `TryFindByAsync` / +`FindByCriteria`, assumindo roundtrip. + +### 8.2 `By` exige membro direto da entidade + +O seletor precisa acessar uma propriedade **do parâmetro da lambda**. Qualquer outra coisa lança +`ArgumentException` em tempo de execução. + +```csharp +// ❌ ArgumentException: não é membro do parâmetro `c` +criteria.By(c => outroObjeto.Nome, valor); + +// ❌ ArgumentException: cadeia profunda (o display name seria resolvido no tipo errado) +criteria.By(c => c.State.Name, "SC"); + +// ✅ membro direto +criteria.By(c => c.Name, valor); + +// ✅ para navegar ou usar lógica além de igualdade, use a sobrecarga de predicado +criteria.By(c => c.State.Name == "SC", byName: "State", propertyName: "stateName", value: "SC"); +``` + +### 8.3 `Id` não entra direto em `By` + +Cuidado: isto **compila**. Como existe conversão implícita de `int` para `Id`, o compilador +infere `TValue = Id` e insere um `Convert` no seletor. O erro só aparece em tempo de execução, +como `ArgumentException` do próprio builder. + +```csharp +Id stateId = 42; + +// ❌ compila, mas lança ArgumentException em tempo de execução: +// "Cannot filter 'StateId' (of type Int32) by an Id<,> wrapper. Pass the underlying value instead..." +db.FindByCriteria().By(c => c.StateId, stateId); + +// ✅ use o valor +db.FindByCriteria().By(c => c.StateId, stateId.Value); +``` + +Em `TryFindAsync`, ao contrário, o wrapper é aceito nas três sobrecargas e o `id.Value` é usado +internamente — tanto em `db.TryFindAsync(id, ct)` quanto em `db.Set().TryFindAsync(id, ct)`. + +### 8.4 `default(FindCriteria)` e `default(Result)` + +`FindCriteria` é struct e detecta o uso não inicializado, lançando `InvalidOperationException` +com mensagem explicativa em `By` e em `TryFindAsync`. Sempre comece por `FindByCriteria(...)`. + +`Result` **não** tem essa guarda: o `default` se comporta como **sucesso com valor nulo**. + +```csharp +// ❌ IsSuccess == true e HasValue devolve true com value == null +Result r = default; +if (r.HasValue(out var order)) { order.Total(); /* NullReferenceException */ } + +// ✅ construa explicitamente +Result ok = order; +Result fail = Problems.NotFound("Order not found", "orderId"); +``` + +Nunca declare `Result` sem inicializar, nem use `new Result()` sem argumentos. + +### 8.5 Acessando o valor: `HasValue`, `HasProblemsOrGetValue` e `EnsureHasValue` + +Além de `HasProblems`/`HasValue`, existem duas formas que evitam checagem dupla — e uma que **lança +exceção**, contrariando a filosofia da biblioteca se usada no fluxo esperado. + +```csharp +// ✅ um único teste, devolve problemas OU valor +if (result.HasProblemsOrGetValue(out var problems, out var order)) + return problems; +// aqui `order` não é nulo + +// ✅ variação com a ordem invertida +if (result.HasValueOrGetProblems(out var value, out var errors)) { /* sucesso */ } + +// ⚠️ EnsureHasValue LANÇA InvalidOperationException se houver problemas. +// Use apenas quando a falha já foi tratada antes e é logicamente impossível aqui. +result.EnsureHasValue(out var entity); + +// ❌ EnsureHasValue não protege contra o `default`: ele só lança quando IsFailure é true. +// Em default(Result) não há problemas, então `bad` volta nulo silenciosamente (ver §8.4). +Result o = default; +o.EnsureHasValue(out var bad); // bad == null, sem exceção +``` + +### 8.6 Ordem de parâmetros de `Problems.InternalError` + +Ver §3: `InternalError` é `(detail, typeId, property)`, invertido em relação às demais fábricas. +Sempre passe `property:` nomeado. + +### 8.7 `out var` repetido no mesmo escopo + +`HasProblems(out var problems)` duas vezes no mesmo método é erro de compilação (CS0128). +Dê nomes distintos: `inputProblems`, `validationProblems`. + +## 9. Boas Práticas - Padronize categorias e status HTTP: - 404 NotFound, 400 InvalidParameter (entrada), 422 ValidationFailed (semântica), 403 NotAllowed, 409 InvalidState, 500 Internal. @@ -519,12 +1036,19 @@ Boas práticas (RFC 9457): - Não sobrescreva campos reservados; use `Extensions` com nomes estáveis e significativos. - Use `Result`/`Result` como fluxo de sucesso/falha: - Componha com `Map`, `Continue`, `Match`/`MatchAsync` para reduzir boilerplate. + - Em sobrecargas `Async` com `TParam` e delegate `Task`, passe `param`, depois o delegate, e `CancellationToken` por último. - Evite exceções para casos esperados; retorne problemas nas falhas. - Valide entrada e regras de domínio: - Modelo com `HasProblems(out Problems?)` ou FluentValidation (`EnsureIsValid`, `ToProblems`). - Em APIs, converta problemas para `ProblemDetails` automaticamente via `OkMatch`/`CreatedMatch`/`NoContentMatch`. +- Documente problemas expostos pela API: + - Registre descrições com `AddProblemDetailsDescriptions`, em código ou JSON. + - Publique `MapProblemDetailsDescriptionPage()` quando consumidores precisarem consultar o catálogo de tipos. + - Para `Problems.Custom`, garanta que todo `typeId` público tenha `title`, `description`, `status` e URI de `type` estáveis. - Persistência e buscas: - - Use `TryFindAsync`/`TryFindByAsync` (EF) e trate `FindResult` com `NotFound`/`ToResult([param])`. + - Use `TryFindAsync`/`TryFindByAsync` (EF) e trate `FindResult` com `NotFound`/`HasInvalidParameter`/`ToResult([param])`. + - Para buscas com dois ou mais campos, chave composta ou filtros condicionais, prefira `FindByCriteria().By(...).TryFindAsync(ct)`. + - Para mensagens `NotFound` multi-critério fora do EF, use `FindCriterion` e `FindResult.Problem(criteria)`. - Propague campos extras (`id`, `entity`, `property/value`) em `Extensions` para rastreabilidade. - Cliente HTTP: - Consuma com `ToResultAsync` (valor ou problemas) e trate `application/problem+json` corretamente. @@ -534,6 +1058,8 @@ Boas práticas (RFC 9457): - Encadeie propriedades com `ChainProperty(parent[, index])` para apontar origem precisa. - Performance: - `Result` é `readonly struct`; aproveite composição leve e evite alocações desnecessárias. +- Assinaturas: + - Consulte a documentação XML das libs no IDE/pacote para confirmar overloads, nomes de parâmetros e tipos de retorno antes de gerar código em APIs menos usadas. ## Resumo @@ -545,18 +1071,42 @@ O uso de `readonly struct` e APIs inlinadas favorece performance, e as extensõe ## Instruções para Ferramentas de IA (GitHub Copilot) -Diretrizes de geração alinhadas às seções 1–7: +Diretrizes de geração alinhadas às seções 1–8. **Antes de gerar código, leia a §1.1 (pacote e `using`) +e a §8 (erros comuns)** — juntas elas cobrem os erros que não aparecem no IntelliSense. + +- Pacotes e `using` + - Resolva o `using` pela tabela da §1.1; pacote e namespace divergem (`OkMatch` → pacote `ApiResults`, + tipos em `HttpResults`, extensions em `Microsoft.AspNetCore.Http`; `ToResultAsync` → pacote `Http`, + namespace `System.Net.Http`; `ToProblemDetails` → pacote `ProblemDetails`, namespace `...Conversions`). + - Extensões de EF e ASP.NET Core vivem em namespaces da Microsoft: instalado o pacote, não há `using` novo. +- Armadilhas obrigatórias (§8) + - `Problems.InternalError` é `(detail, typeId, property)`, invertido em relação às demais fábricas: + sempre passe `property:` nomeado. + - Nunca produza `default(Result)` nem `new Result()`: reporta sucesso com valor nulo. + - Em `FindCriteria.By`, o seletor deve ser membro **direto** do parâmetro, e o valor deve ser o valor + cru (`id.Value`), nunca um `Id<,>`. + - Cada `out var` precisa de nome único no escopo (CS0128). - Problemas e categorias - Gere problemas com a categoria correta: `InvalidParameter` (400 entrada), `ValidationFailed` (422 semântica), `NotFound` (404), `InvalidState` (409), `NotAllowed` (403), `InternalServerError` (500), `CustomProblem` (typeId descrito). - Use `Property` para apontar o campo e `With(key, value)` para contexto adicional; `ChainProperty(parent[, index])` para caminhos. - Resultados e composição - Retorne `Result`/`Result` em serviços/handlers; componha com `Map`, `Continue`, `Match` e `MatchAsync`. + - Para métodos `Async` com `TParam` e callbacks que retornam `Task`/`Task`, gere chamadas no formato `MetodoAsync(param, static (..., token) => ..., ct)`. - Evite exceções para fluxos esperados; converta para problemas e propague via `Result`. - APIs Web (servidor) - Converta `Result`/`Result` em `OkMatch`, `CreatedMatch` (com `Location`) e `NoContentMatch`. - - Configure `ProblemDetailsOptions` e descreva `typeId` para problemas customizados; respeite RFC 9457 (`type`, `title`, `status`, `detail`, `instance`). + - Para Minimal APIs, prefira `app.MapGroup("/group-route").WithExceptionFilter()` e mapeie as rotas no grupo; use o filtro apenas para exceptions inesperadas. + - Configure `ProblemDetailsOptions`, registre descrições com `AddProblemDetailsDescriptions` e exponha `MapProblemDetailsDescriptionPage()` quando a API deve documentar seus tipos. + - Descreva todo `typeId` customizado; respeite RFC 9457 (`type`, `title`, `status`, `detail`, `instance`). - Entity Framework - Use `TryFindAsync`/`TryFindByAsync` para obter `FindResult`; converta para `Result` com `ToResult([param])`. + - Para `Id`, pode chamar tanto `db.TryFindAsync(id, ct)` quanto `db.Set().TryFindAsync(id, ct)`; o valor real usado é `id.Value`. + - Para busca por um campo simples, prefira `TryFindByAsync(e => e.Property == value, ct)` ou a sobrecarga de seletor `TryFindByAsync(e => e.Property, value, ct)`. + - Para dois ou mais critérios, chave composta ou filtros condicionais, gere `db.FindByCriteria().By(...).By(...).TryFindAsync(ct)`. + - Lembre que `FindCriteria.By` retorna nova instância; em filtros condicionais, reatribua `criteria = criteria.By(...)`. + - Use a sobrecarga `By(predicate, byName, propertyName, value)` quando o critério tiver `StartsWith`, range, `OR` ou outra lógica que não seja igualdade simples. + - Ao gerar mensagens manuais de não encontrado com múltiplos campos, use `FindCriterion[]` e `FindResult.Problem(criteria)`. + - Não tente documentar `!=`, `>`, `<`, `||`, membro profundo (`e.State.Name`) ou comparação membro-a-membro (`e.A == e.B`) como se fossem critérios `AND` simples; nesses casos o `TryFindByAsync(predicate)` degrada para `NotFound` genérico ou deve receber dados explícitos. - Ao não encontrar, retorne `NotFound` padronizado com `Extensions` (`id`, `entity`, `property/value`). - Cliente HTTP - Consuma com `ToResultAsync` (valor ou problemas); trate `application/problem+json` e use `FailureTypeReader` para conteúdos não-ProblemDetails. @@ -565,9 +1115,10 @@ Diretrizes de geração alinhadas às seções 1–7: - Performance e observabilidade - Prefira `Result` (`readonly struct`) para menor alocação; use `With`/`Extensions` para dados de diagnóstico. -Padrões de prompt para Copilot: +Padrões de prompt para Agentes: - “Implemente um serviço que valide entrada com FluentValidation, retorne `Result` e mapeie para `CreatedMatch` com Location.” - “Crie uma consulta EF com `TryFindByAsync` por `Name`; retorne `OkMatch` quando encontrado e `ProblemDetails 404` quando não.” +- “Crie uma busca EF composta com `FindByCriteria`: filtre por `StateId` e `Name`, retorne `FindResult` e documente o `NotFound` com os dois critérios.” - “Compose um `Result` em `Result` usando `Map` e trate falhas com `Match` → `Problems.AsResult()`.” - “Defina um `Problems.Custom` com `typeId` e configure `ProblemDetailsOptions` seguindo RFC 9457 (URI absoluta em `type`).” -- “Consuma um endpoint com `HttpClient` e `ToResultAsync`; em falha, itere `Problems` e exiba `category`/`detail`.” \ No newline at end of file +- “Consuma um endpoint com `HttpClient` e `ToResultAsync`; em falha, itere `Problems` e exiba `category`/`detail`.” diff --git a/RoyalCode.EnterprisePatterns/.docs/search.md b/RoyalCode.EnterprisePatterns/.docs/search.md deleted file mode 100644 index e5f2fb7..0000000 --- a/RoyalCode.EnterprisePatterns/.docs/search.md +++ /dev/null @@ -1,278 +0,0 @@ -# Documentação da API SmartSearch (Filters, Specifiers, Selectors, Sorting, Criteria) - -Esta documentação apresenta os conceitos, funcionalidades e exemplos práticos para usar a família de bibliotecas SmartSearch em projetos .NET -(alvo: .NET 8, .NET 9 e .NET 10). Também serve como referência para ferramentas de IA (ex.: GitHub Copilot) -para gerar código correto com base na API e nos padrões desta solução. - -Sumário -1. Introdução -2. Pacotes e responsabilidades -3. Conceitos centrais -4. Como o filtro é resolvido em expressão -5. Disjunção (OR) por grupo e por nome/caminho -6. Sorting e paginação com ResultList -7. Projeção para DTO (Selector) -8. Orquestração com Criteria e EF Core -9. Exemplos de uso -10. Pontos de extensão e configuração -11. Boas práticas -12. Resumo -13. Instruções para Ferramentas de IA (GitHub Copilot) - -## 1. Introdução - -SmartSearch implementa o padrão Specification/Filter-Specifier para compor filtros declarativos em `IQueryable`, gerar expressões LINQ traduzíveis pelo EF Core, aplicar ordenação e paginação, e projetar entidades em DTOs através de `Selector`. O objetivo é criar componentes de busca reutilizáveis, desacoplados, testáveis e extensíveis, evitando lógica manual e repetitiva. - -Benefícios principais: -- Filtros declarativos via atributos e convenções, com ignorância de valores vazios. -- Composição automática de predicados (Equal, Like, In, Range etc.). -- OR-disjunction por grupos e por nomes/caminhos contendo `Or`. -- Ordenação dinâmica por nomes de propriedades (inclusive caminhos aninhados). -- Projeção expressiva para DTO (nested, coleções, enums, nullables). -- Integração com EF Core via `ISearchManager` e `ICriteria`. -- Extensões para fábricas de predicados e geradores de expressão customizados. - -## 2. Pacotes e responsabilidades - -- `RoyalCode.SmartSearch.Core`: Abstrações para filtro, specifier, resultado (inclui paginação), sorting e seleção. -- `RoyalCode.SmartSearch.Linq`: Resolução de propriedades, geração de expressões, `Selector` e sorting dinâmico. -- `RoyalCode.SmartSearch.EntityFramework`: Integração EF Core, DI e `ISearchManager`/`ICriteria`. -- `RoyalCode.SmartSearch.Abstractions`: Contratos e interfaces compartilhadas. - -## 3. Conceitos centrais - -- `Filter`: objeto com propriedades que representam critérios de busca. Atributos nas propriedades controlam operador, negação, caminho de destino (`TargetPropertyPath`), e regras de ignorar valores vazios. -- `Specifier`: aplica o filtro em um `IQueryable` construindo a expressão-predicado. -- `Selector`: mapeia entidades para DTOs via expressão gerada automaticamente. -- `Sorting`: define ordenação dinâmica por propriedades, usado também na construção de `ResultList` com metadados. -- `Criteria`: orquestra filtro, ordenação, paginação, projeção e coleta dos resultados. - -Principais atributos e opções (exemplos): -- `Criterion`: define o alvo e operador; `TargetPropertyPath` pode apontar caminho aninhado. -- `Disjuction("alias")`: agrupa propriedades em OR. -- `ComplexFilter`: elege um membro para tratamento de filtro complexo (nested/owned types). -- `FilterExpressionGenerator`: delega a criação da expressão para gerador customizado. -- `DisableOrFromName` (em `Criterion`): desativa a inferência automática de OR quando o nome da propriedade (ou caminho) contém o token `Or`; trata o membro como critério único. - -## 4. Como o filtro é resolvido em expressão - -A pipeline de resolução constrói uma lista de resoluções (`ICriterionResolution`) por propriedade do filtro e compõe uma única expressão final. - -Etapas (alto nível): -1) Fábricas de predicado configuradas: propriedades mapeadas manualmente geram resoluções dedicadas. -2) Disjunção por `[Disjuction]`: membros do mesmo grupo se tornam uma única resolução com OR. -3) `Or` em nome/caminho: propriedades ou paths com `Or` são divididos em partes e resolvidos como OR. -4) ComplexFilter: propriedades com `[ComplexFilter]` (no tipo ou no membro) são tratadas como filtro composto. -5) Padrão: `DefaultOperatorCriterionResolution` escolhe o operador conforme o tipo (strings → Like; coleções → In; numéricos/datas → Equal), com guardas para ignorar valores vazios. - -Ignorar valores vazios ("IgnoreIfIsEmpty") cobre: `string` em branco, `Nullable` não definido, coleções vazias, structs default. - -## 5. Disjunção (OR) por grupo e por nome/caminho - -- `[Disjuction("g1")]`: quando múltiplas propriedades compartilham o mesmo alias, aplicam OR entre elas; entradas vazias são ignoradas. -- `Or` em propriedade/caminho: nomes como `FirstNameOrMiddleNameOrLastName` ou paths como `FirstNameOrLastName` dividem em vários destinos, combinados com OR. - - Opt-out por propriedade: use `[Criterion(DisableOrFromName = true)]` quando o token `Or` fizer parte do nome mas não indicar disjunção (ex.: `ColorOrSizePreference`). - -Casos esperados: -- Todos vazios → nenhum `Where` aplicado. -- Um valor presente → condição única. -- Múltiplos valores → OR entre as condições. - -## 6. Sorting e paginação com ResultList - -`Sorting` permite ordenar por nome de propriedade e direção. A paginação é modelada em `ResultList` com campos: `Page`, `ItemsPerPage`, `Count`, `Pages`, `Sortings`, e `Items`. - -Compatibilidade: ordenações podem ser fornecidas via JSON/string e são serializáveis. - -## 7. Projeção para DTO (Selector) - -`DefaultSelectorExpressionGenerator.Generate()` mapeia: -- Propriedades de mesmo nome. -- Caminhos aninhados (ex.: `Complex.Value` → `ComplexValue`). -- Normalização de `Nullable` para tipos não-nulos em DTO. -- Enum ↔ enum com conversão de valor. -- Sub-selects aninhados e coleções (element mapping), inclusive multi-nível. - -## 8. Orquestração com Criteria e EF Core - -Registre as entidades e configurações no DI e use `ISearchManager` para obter `ICriteria`: - -```csharp -services.AddEntityFrameworkSearches(cfg => -{ - cfg.Add(); - cfg.AddOrderBy("Name", x => x.Name.First); - cfg.AddSelector(x => new MyDto { Id = x.Id, Name = x.Name.First }); -}); - -var manager = provider.GetRequiredService>(); -var criteria = manager.Criteria(); -``` - -`ICriteria` fornece: -- `FilterBy(filter)` -- `OrderBy(ISorting)`/`OrderBy(IEnumerable)` -- `Select()` -- `Collect()`/`CollectAsync()` e modo busca `AsSearch().ToList()/ToListAsync()` -- `Exists()`, `FirstOrDefault()`, `Single()` com validação de cardinalidade - -Diferenças entre `Collect/CollectAsync` e `AsSearch().ToList/ToListAsync`: -- `Collect/CollectAsync`: retorna a coleção de itens já filtrados/ordenados/projetados, sem metadados de paginação. Em EF Core, mantém as entidades anexadas ao `ChangeTracker` (rastreadas), permitindo atualizações subsequentes e detecção de mudanças. -- `AsSearch().ToList/ToListAsync`: retorna `ResultList` com metadados de busca (Page, ItemsPerPage, Count, Pages, Sortings) aplicando defaults configurados. Use quando precisa de paginação, ordenação serializável e informações agregadas para UI/APIs. - -Quando usar: -- Use `Collect`/`CollectAsync` para rotinas internas, processamento batch, cenários em que você pretende modificar entidades (EF Core tracking) ou precisa manter o estado rastreado após a consulta. Evite em endpoints públicos se não precisar de tracking para reduzir overhead. -- Use `AsSearch().ToList/ToListAsync` em APIs/UI que exibem páginas e ordenações, quando não precisa manter entidades rastreadas pelo EF Core, priorizando resultados materializados com metadados e menor custo de tracking. - -## 9. Exemplos de uso - -### 9.1. Busca simples com filtro -```csharp -public sealed class SimpleModel { public int Id { get; set; } public string Name { get; set; } = string.Empty; } -public sealed class SimpleFilter { [Criterion] public string? Name { get; set; } } - -var criteria = provider.GetRequiredService>(); -criteria.FilterBy(new SimpleFilter { Name = "B" }); -var results = criteria.Collect(); // retorna apenas registros com Name semelhante a "B" (Like) -``` - -### 9.2. OR por nome de propriedade -```csharp -public sealed class PersonFilter -{ - [Criterion] // string → operador Like - public string? FirstNameOrMiddleNameOrLastName { get; set; } -} - -var res = criteria.FilterBy(new PersonFilter { FirstNameOrMiddleNameOrLastName = "Ann" }).Collect(); -// Aplica OR entre os paths FirstName, MiddleName e LastName ignorando campos vazios -``` - -### 9.3. OR por TargetPropertyPath -```csharp -public sealed class QueryFilter -{ - [Criterion(TargetPropertyPath = "FirstNameOrLastName")] public string? Query { get; set; } -} - -var res = criteria.FilterBy(new QueryFilter { Query = "Jo" }).Collect(); -// Aplica OR entre FirstName e LastName -``` - -### 9.4. Disjunção por grupo -```csharp -public sealed class DisjunctionFilter -{ - [Disjuction("g1")] public string? Email { get; set; } - [Disjuction("g1")] public string? Phone { get; set; } -} - -var res = criteria.FilterBy(new DisjunctionFilter { Email = "@domain" }).Collect(); -// Apenas Email gerará condição; Phone vazio é ignorado; múltiplos valores criam OR -``` - -### 9.5. ComplexFilter (tipos complexos/owned) -```csharp -public readonly record struct Email(string Value); -public sealed class User { public Email? Email { get; set; } } - -public sealed class UserFilter -{ - [Criterion("Email.Value")] public string? Email { get; set; } // mapeia para caminho aninhado -} - -criteria.FilterBy(new UserFilter { Email = "@royalcode" }); -var list = criteria.Collect(); -``` - -### 9.6. Sorting e paginação -```csharp -criteria.OrderBy(new Sorting { OrderBy = "Name", Direction = ListSortDirection.Ascending }); -var page1 = criteria.AsSearch().ToList(); -// page1 contém metadados (Page, ItemsPerPage, Count, Pages, Sortings) -``` - -### 9.7. Projeção para DTO -```csharp -public sealed class PersonDto { public int Id { get; set; } public string Name { get; set; } = string.Empty; } - -criteria.Select(); -var dtos = criteria.AsSearch().ToList(); -// Projeta conforme configurado (por nome ou configurador AddSelector) -``` - -### 9.8. Cardinalidade e existência -```csharp -criteria.FilterBy(new SimpleFilter { Name = "A" }); -var exists = criteria.Exists(); // true/false -var first = criteria.FirstOrDefault(); -var single = criteria.Single(); // lança se houver 0 ou >1; use filtros/ordenadores adequados -``` - -## 10. Pontos de extensão e configuração - -Via `ISearchConfigurations`: -- `Add()`: registra entidade para buscas. -- `AddOrderBy(name, keySelector)`: registra ordenação por nome. -- `AddSelector(expr)`: define seleção para DTO. -- `ConfigureSpecifierGenerator(opt => opt.For(f => f.Prop).Predicate(val => e => ...))`: mapeia fábrica de predicado customizada. - -`FilterExpressionGenerator`: implemente `ISpecifierExpressionGenerator` para cenários complexos (ex.: períodos): -```csharp -public sealed class PeriodSpecifierExpressionGenerator : ISpecifierExpressionGenerator -{ - public static Expression GenerateExpression(ExpressionGeneratorContext ctx) - { - // constrói a expressão Where com range calculado - // retorno é uma atribuição no `ctx.Query` com Queryable.Where(...) - // ver exemplos em README - throw new NotImplementedException(); - } -} -``` - -## 11. Boas práticas - -- Prefira filtros declarativos com atributos e caminhos aninhados claros. -- Use OR por grupos (`Disjuction`) ou por `Or` em nomes/caminhos quando fizer sentido de negócio. -- Configure ordenações nomeadas via `AddOrderBy` para evitar strings mágicas espalhadas. -- Projeção por `Selector` mantém consultas traduzíveis pelo EF Core; evite lógica não traduzível. -- Ignore vazios para não poluir o `Where` com condições inúteis; valide entrada com SmartValidations. -- Em APIs, componha com SmartProblems: converta falhas para `ProblemDetails` (RFC 9457). - -## 12. Resumo - -SmartSearch fornece uma forma padronizada, declarativa e performática de compor buscas em `IQueryable` e EF Core. Com `Filter` + `Specifier`, você gera predicados automaticamente; com `Sorting` e `ResultList`, você controla ordenação e paginação; com `Selector`, projeta DTOs complexos de forma segura. A integração via `ICriteria` orquestra todas as etapas (filtro, ordenação, seleção e coleta), e pontos de extensão permitem adaptar o comportamento a regras avançadas. - -## 13. Instruções para Ferramentas de IA (GitHub Copilot) - -Objetivo: gerar buscas seguindo o contrato da API (Filter + Specifier + Criteria) e produzir consultas EF Core traduzíveis. - -Princípios obrigatórios -- Modele filtros como classes com propriedades e anote com `Criterion`/`Disjuction`/`ComplexFilter` quando necessário. -- Use `ICriteria` para aplicar `FilterBy`, `OrderBy`, `Select` e `Collect`/`AsSearch`. -- Evite construir `Expression` manualmente quando um atributo cobre o caso; use `FilterExpressionGenerator` apenas para cenários avançados. -- Preserve nomes/caminhos de propriedades usando `TargetPropertyPath` e convenções com `Or` para OR. - -Padrões de implementação -- Filtro simples: `class Filter { [Criterion] public string? Name { get; set; } }` e `criteria.FilterBy(filter)`. -- Disjunção: `[Disjuction("g1")]` em múltiplos membros do filtro para OR. -- OR por caminho: `[Criterion(TargetPropertyPath = "FirstNameOrLastName")]`. -- Projeção: `criteria.Select()` usando mapeamento por nome ou configurado. -- Ordenação: `criteria.OrderBy(new Sorting { OrderBy = "Name" })`. -- Coleta vs Busca paginada: para lista simples, prefira `Collect/CollectAsync`; para UI/APIs com paginação e metadados, use `AsSearch().ToList/ToListAsync` e leia `ResultList`. -- EF Core Tracking: `Collect` mantém entidades rastreadas no `ChangeTracker`; `AsSearch().ToList` não anexa entidades. Escolha conforme necessidade de atualização subsequente vs desempenho. - -Integrações -- EF Core: obtenha `ICriteria` via `ISearchManager` e registre com `AddEntityFrameworkSearches(cfg => cfg.Add())`. -- SmartProblems/SmartValidations: valide entrada e converta falhas para `ProblemDetails` antes da busca. - -Antipadrões (evitar) -- Criar `Where` manual com strings de propriedade sem usar atributos/convenções da lib. -- Usar expressões não traduzíveis pelo EF em `Selector`. -- Ignorar valores vazios e forçar filtros que resultam em consultas ineficientes. - -Exemplos de prompts corretos -- "Implemente um filtro com `[Disjuction("g1")]` para Email/Phone e aplique com `ICriteria.FilterBy(filter)` retornando `ResultList`." -- "Crie um `Selector` para `Order -> OrderDto` e projete com `criteria.Select().AsSearch().ToList()`." -- "Configure `AddOrderBy(\"CustomerName\", x => x.Customer.Name)` e gere `criteria.OrderBy(new Sorting { OrderBy = \"CustomerName\" })`." diff --git a/RoyalCode.EnterprisePatterns/.docs/selector.ai-rules.md b/RoyalCode.EnterprisePatterns/.docs/selector.ai-rules.md new file mode 100644 index 0000000..acc1f71 --- /dev/null +++ b/RoyalCode.EnterprisePatterns/.docs/selector.ai-rules.md @@ -0,0 +1,455 @@ +# SmartSelector — Regras para IA + +Regras operacionais para gerar código com SmartSelector em projetos .NET. Para explicações e contexto, consulte [`selector.md`](selector.md). + +> **Verificado contra:** `RoyalCode.SmartSelector` e `RoyalCode.SmartSelector.Generators` **0.5.0** — .NET 8 / 9 / 10; generator `netstandard2.0` como analyzer. +> **Precedência das fontes:** documentação XML/IntelliSense da versão instalada > este arquivo > [`selector.md`](selector.md). +> Com versão divergente, confirme atributos, membros gerados e diagnósticos no IDE. + +## 1. Pacotes e `using` + +Instale os dois pacotes na mesma versão: + +```xml + + + + +``` + +| API | `using` | Pacote | +|---|---|---| +| `AutoSelect`, `AutoProperties`, `AutoDetails`, `MapFrom` | `RoyalCode.SmartSelector` | `RoyalCode.SmartSelector` | +| arquivos gerados e diagnósticos `RCSS*` | nenhum namespace consumidor | `RoyalCode.SmartSelector.Generators` | + +Nunca referencie o projeto/pacote do generator como assembly de runtime. + +## 2. Regras invioláveis + +1. Declare todo DTO processado como `partial`. +2. Declare também como `partial` cada tipo que contém um DTO aninhado. +3. Coloque DTOs de `AutoSelect` dentro de um namespace. +4. Não gere DTO de destino genérico nem dentro de tipo genérico. +5. Use `[AutoSelect, AutoProperties]`, nunca `[AutoSelect, AutoProperties]`. +6. Use `[AutoProperties]` somente quando quiser propriedades sem expressão, `From` ou extensões. +7. Não presuma que `[AutoSelect]` sozinho cria propriedades; declare-as ou adicione `AutoProperties`. +8. Trate `Exclude` e `Flattening` como case-sensitive e prefira `nameof` para membros diretos. +9. Use `MapFrom` para renome ou caminho explícito; cada segmento deve ser propriedade pública legível. +10. Faça o destino nullable quando a origem ou uma navegação do caminho puder ser null; não ignore `RCSS015`. +11. Não edite arquivos `.g.cs`. +12. Não invente resolvers, callbacks, formatters ou configuração DI: essas APIs não existem no SmartSelector 0.5.0. +13. Para cálculos e transformações customizadas, escreva uma expressão LINQ manual. +14. Em `IQueryable`, use a expressão/extensão gerada e valide tradução com o provider real. + +## 3. Matriz de decisão + +| Necessidade | Gere | +|---|---| +| DTO simples com propriedades automáticas | `[AutoSelect, AutoProperties] partial class EntityDetails` | +| DTO com subconjunto explícito | `[AutoSelect]` e propriedades manuais | +| DTO automático sem campos sensíveis | `AutoProperties(Exclude = [nameof(...)])` | +| opções diretamente no `AutoSelect` | `AutoSelect(Exclude = [...], Flattening = [...])` | +| gerar somente propriedades | `[AutoProperties]` | +| alias de membro direto | `[MapFrom(nameof(Entity.Member))]` | +| caminho aninhado explícito | `[MapFrom("Navigation.Member")]` | +| flattening profundo já conhecido | propriedade manual `CustomerAddressCity` | +| gerar flattening de uma navegação raiz | `Flattening = [nameof(Entity.Address)]` | +| gerar/completar tipo de detalhe aninhado | `[AutoDetails]` na propriedade | +| objeto aninhado com forma customizada | declare o DTO aninhado manualmente | +| coleção de objetos | declare `IReadOnlyList` ou `ItemDetails[]` | +| cálculo, agregação, formatação | `Expression>` manual | + +## 4. Padrão canônico + +```csharp +using RoyalCode.SmartSelector; + +namespace MyApp.Users; + +public sealed class User +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string PasswordHash { get; set; } = string.Empty; +} + +[AutoSelect, + AutoProperties(Exclude = [nameof(User.PasswordHash)])] +public partial class UserDetails { } +``` + +Consumo: + +```csharp +var list = await db.Users + .SelectUserDetails() + .ToListAsync(ct); + +UserDetails dto = UserDetails.From(user); +UserDetails dto2 = user.ToUserDetails(); + +Expression> expression = + UserDetails.SelectUserExpression; +``` + +Nomes gerados para origem `User` e destino `UserDetails`: + +```csharp +UserDetails.SelectUserExpression; +UserDetails.From(User user); +query.SelectUserDetails(); // IQueryable +items.SelectUserDetails(); // IEnumerable +user.ToUserDetails(); // User +``` + +## 5. Escolha de `AutoProperties` + +Projeção e propriedades: + +```csharp +[AutoSelect, AutoProperties] +public partial class ProductDetails { } +``` + +Somente propriedades: + +```csharp +[AutoProperties] +public partial class ProductSnapshot { } +``` + +Configuração direta também ativa propriedades automáticas: + +```csharp +[AutoSelect( + Exclude = [nameof(Order.InternalCode)], + Flattening = [nameof(Order.Customer)])] +public partial class OrderDetails { } +``` + +Não gere: + +```csharp +[AutoSelect, AutoProperties] // RCSS003 +public partial class ProductDetails { } +``` + +Nem: + +```csharp +[AutoProperties] // RCSS007: falta AutoSelect +public partial class ProductDetails { } +``` + +## 6. Tipos de propriedades automáticas + +Inclua automaticamente apenas: + +- `bool`, `char`, `string`; +- tipos numéricos e `decimal`; +- `DateTime`; +- enums e structs; +- versões nullable suportadas; +- arrays de tipos simples, enums ou structs; +- coleções genéricas que implementam `IEnumerable` de tipo simples, enum ou struct. + +Não espere geração automática de classes complexas. Para uma classe complexa, escolha uma destas formas: + +```csharp +// Forma customizada, declarada manualmente. +public AddressDetails Address { get; set; } = new(); + +// Geração do tipo declarado. +[AutoDetails] +public AddressDetails Address { get; set; } = new(); + +// Propriedades achatadas automáticas. +[AutoSelect(Flattening = [nameof(Customer.Address)])] +``` + +`AutoProperties` não duplica propriedades públicas já declaradas no DTO. + +## 7. Precedência de mapeamento + +Ao gerar uma propriedade do DTO, aplique esta preferência: + +1. `MapFrom` com caminho aninhado explícito; +2. `MapFrom` com membro direto; +3. membro direto de mesmo nome; +4. flattening por nome concatenado; +5. projeção estrutural de objeto/coleção compatível. + +Use `MapFrom` para eliminar ambiguidade. Não dependa da ordem de descoberta quando houver `RCSS010`. + +## 8. `MapFrom` + +Membro direto: + +```csharp +[MapFrom(nameof(Product.Name))] +public string DisplayName { get; set; } = string.Empty; +``` + +Caminho aninhado: + +```csharp +[MapFrom("Warehouse.Location")] +public string? Location { get; set; } +``` + +Regras: + +- use `nameof` para membro direto; +- use string com pontos para caminho aninhado; +- exija propriedade pública legível em cada segmento; +- preserve maiúsculas/minúsculas exatas; +- faça o destino nullable se qualquer segmento puder ser null; +- caminho explícito prevalece sobre membro direto homônimo; +- trate `RCSS017` em caminho aninhado e `RCSS001` em nome direto sem correspondência como erros de contrato. + +Não gere chamadas de método, indexadores ou expressões dentro de `MapFrom`: + +```csharp +[MapFrom("Items[0].Name")] // inválido: caminho aninhado não legível +[MapFrom("GetName()")] // inválido: não corresponde a uma propriedade direta +``` + +## 9. Flattening + +Convenção para propriedade manual: + +```csharp +[AutoSelect] +public partial class OrderDetails +{ + // source.Customer.Address.City + public string CustomerAddressCity { get; set; } = string.Empty; +} +``` + +Geração automática a partir de navegação raiz: + +```csharp +[AutoSelect(Flattening = [nameof(Customer.Address)])] +public partial class CustomerDetails { } +``` + +Se `Address` contém `City` e `Zip`, espere `AddressCity` e `AddressZip`. Não espere a propriedade complexa `Address` nessa geração automática. + +Se mais de um caminho produzir o mesmo nome, use `MapFrom`; `RCSS010` é warning de ambiguidade. + +## 10. Objetos, coleções e arrays + +Objeto aninhado manual: + +```csharp +[AutoSelect] +public partial class PostDetails +{ + public AuthorDetails Author { get; set; } = new(); +} + +public class AuthorDetails +{ + public string Name { get; set; } = string.Empty; +} +``` + +Coleção de DTOs: + +```csharp +public IReadOnlyList Comments { get; set; } = []; +``` + +Espere emissão com `Select(...).ToList()` quando o destino exigir lista. + +Array de DTOs: + +```csharp +public ItemDetails[] Items { get; set; } = []; +``` + +Espere emissão com `Select(...).ToArray()`. Arrays simples compatíveis podem ser gerados por `AutoProperties` e atribuídos diretamente. + +## 11. `AutoDetails` + +Padrão: + +```csharp +[AutoSelect, AutoProperties] +public partial class CustomerDetails +{ + [AutoDetails] + public AddressDto Address { get; set; } = new(); +} +``` + +Regras: + +- use o tipo escrito na propriedade como nome exato do detalhe gerado; +- se esse tipo já existir, declare-o `partial`; +- não solicite o mesmo tipo em duas propriedades; +- mantenha a acessibilidade do tipo compatível com a propriedade; +- use `Exclude`/`Flattening` no próprio `AutoDetails` quando necessário; +- use somente dentro de um DTO cujo processamento automático esteja ativo. + +Tipo parcial preexistente: + +```csharp +public partial class AddressDto +{ + public string City { get; set; } = string.Empty; +} +``` + +O generator completa somente as propriedades ausentes. + +## 12. Nulabilidade + +Use esta matriz: + +| Origem | Destino a gerar | Resultado | +|---|---|---| +| escalar nullable | nullable | propagação natural | +| navegação nullable | DTO nullable | condicional com `null` | +| caminho por pai nullable | valor nullable | condicional com `null`/`default(T?)` | +| coleção nullable | coleção nullable | condicional com `null` | +| coleção nullable | coleção não nullable | coleção vazia + `RCSS016` | +| array nullable | array não nullable | `Array.Empty()` + `RCSS016` | +| qualquer origem nullable | destino não nullable escalar/objeto | `RCSS015`; corrija o contrato | + +Preferência: + +```csharp +public WarehouseDetails? Warehouse { get; set; } +public string? AddressCity { get; set; } +public IReadOnlyList Items { get; set; } = []; +``` + +Não silencie `RCSS015` sem decidir como o domínio trata `null`. O generator preserva o comportamento anterior; ele não cria valor default seguro para escalar ou objeto. + +Código `#nullable disable` não recebe a mesma inferência baseada em anotações. Prefira `#nullable enable` em código novo. + +## 13. DTOs aninhados e tipos de origem + +DTO aninhado válido: + +```csharp +public partial class Contracts +{ + [AutoSelect] + public partial class UserDetails + { + public int Id { get; set; } + } +} +``` + +Exija `partial` no DTO e em `Contracts`. + +Não gere: + +```csharp +public partial class Details { } // RCSS008 +public partial class Container +{ + public partial class Details { } // RCSS008 +} +``` + +O tipo de origem pode ser qualificado, `global::`, aninhado ou genérico construído: + +```csharp +[AutoSelect] +[AutoSelect>] +``` + +## 14. LINQ e Entity Framework Core + +Para consulta, gere: + +```csharp +var result = await db.Orders + .Where(order => order.Active) + .OrderBy(order => order.Id) + .SelectOrderDetails() + .ToListAsync(ct); +``` + +Ou: + +```csharp +var query = db.Orders.Select(OrderDetails.SelectOrderExpression); +``` + +Regras: + +- filtre e ordene antes da projeção quando usar membros exclusivos da entidade; +- não compile a expressão para passá-la ao `IQueryable`; +- use `From`/`ToDetails` somente para objetos já materializados; +- propague `CancellationToken` nos terminais async do EF Core; +- teste objetos, coleções, arrays e caminhos nullable com o provider real. + +## 15. Diagnósticos — ação obrigatória + +| ID | Ação | +|---|---| +| `RCSS000` | torne DTO e contêineres `partial` e corrija o uso de `AutoSelect` | +| `RCSS001` | alinhe nome, use `MapFrom` ou exclua a propriedade | +| `RCSS002` | alinhe os tipos ou use expressão manual | +| `RCSS003` | troque `AutoProperties` por `AutoProperties` junto de `AutoSelect` | +| `RCSS004` | remova uma das duas formas de `AutoProperties` | +| `RCSS005` | use classe ou struct válida como `TFrom` | +| `RCSS006` | adicione `partial` à classe de `AutoProperties` | +| `RCSS007` | adicione `AutoSelect` ou use `AutoProperties` | +| `RCSS008` | remova genéricos do DTO e dos tipos contêineres | +| `RCSS010` | desambigue com `MapFrom` ou renomeie | +| `RCSS011` | mova o DTO para um namespace | +| `RCSS012` | torne o tipo existente de `AutoDetails` parcial | +| `RCSS013` | deixe somente uma propriedade gerar o tipo de `AutoDetails` | +| `RCSS014` | aumente a acessibilidade do tipo de `AutoDetails` | +| `RCSS015` | torne o destino nullable, altere a origem ou exclua a propriedade | +| `RCSS016` | confirme conscientemente a semântica null → coleção vazia | +| `RCSS017` | corrija o caminho aninhado público legível de `MapFrom` | + +Não invente `RCSS009`; ele não existe na versão 0.5.0. + +## 16. Anti-padrões + +- Não use `[AutoSelect]` em classe vazia esperando propriedades. +- Não combine `AutoSelect` com `AutoProperties`. +- Não use `AutoProperties` não genérico sem `AutoSelect`. +- Não declare DTO ou contêiner não `partial`. +- Não declare DTO de destino genérico. +- Não coloque DTO de `AutoSelect` no namespace global. +- Não use string para membro direto quando `nameof` é possível. +- Não use caminho `MapFrom` com método, campo, indexador ou segmento privado. +- Não ignore `RCSS010`, `RCSS015` ou `RCSS016` automaticamente. +- Não modele propriedade não nullable apenas para evitar `?`. +- Não espere que `Exclude` remova uma propriedade manual já declarada do DTO; ele controla geração automática. +- Não edite ou versione uma correção manual em `.g.cs` como fonte da solução. +- Não use `From` dentro de `IQueryable`; use a expressão/extensão gerada. +- Não crie configuração DI, resolver ou formatter inexistente. +- Não force SmartSelector em projeções calculadas; escreva expressão manual. +- Não assuma tradução idêntica entre providers EF Core. + +## 17. Checklist antes de entregar o código + +- [ ] Runtime e generator usam a mesma versão. +- [ ] `using RoyalCode.SmartSelector` está presente. +- [ ] DTO e tipos contêineres são `partial`. +- [ ] DTO está em namespace e não é genérico. +- [ ] A matriz de decisão escolheu a forma correta de `AutoProperties`. +- [ ] Campos sensíveis/internos estão em `Exclude` ou fora do DTO. +- [ ] `nameof` foi usado em `Exclude`, `Flattening` e `MapFrom` direto. +- [ ] Caminhos aninhados de `MapFrom` são públicos, legíveis e case-sensitive. +- [ ] Ambiguidade de flattening foi eliminada. +- [ ] Nulabilidade do destino representa a origem inteira do caminho. +- [ ] `RCSS015` e `RCSS016` foram avaliados conscientemente. +- [ ] Objetos e itens de coleção possuem propriedades compatíveis. +- [ ] `AutoDetails` não disputa o mesmo tipo e respeita acessibilidade/`partial`. +- [ ] `IQueryable` usa `Select{Dto}()` ou `Select{Source}Expression`. +- [ ] Projeção relevante foi testada com o provider real. diff --git a/RoyalCode.EnterprisePatterns/.docs/selector.md b/RoyalCode.EnterprisePatterns/.docs/selector.md index c62b5bd..8c1902e 100644 --- a/RoyalCode.EnterprisePatterns/.docs/selector.md +++ b/RoyalCode.EnterprisePatterns/.docs/selector.md @@ -1,240 +1,651 @@ -# Documentação da API SmartSelector (Selectors, AutoSelect, AutoProperties) +# Documentação da API SmartSelector -Esta documentação apresenta os conceitos, funcionalidades e exemplos práticos para usar a família de bibliotecas SmartSelector em projetos .NET (alvo: .NET 8, .NET 9 e .NET 10 runtime; Generator .NET Standard 2.0 como Analyzer). Também serve como referência para ferramentas de IA (ex.: GitHub Copilot) gerarem código correto com base na API e nos padrões desta solução. SmartSelector integra-se naturalmente com SmartSearch para compor consultas EF Core com projeções tipadas. +SmartSelector é uma família de bibliotecas .NET que gera projeções fortemente tipadas entre entidades e DTOs. O Source Generator cria expressões LINQ reutilizáveis, métodos de conversão, extensões de consulta e, quando solicitado, propriedades do DTO. -Sumário -1. Introdução -2. Pacotes e responsabilidades -3. Conceitos centrais -4. Atributos disponíveis e regras -5. Membros gerados pelo Source Generator -6. Propriedades suportadas em `AutoProperties` -7. Flattening (caminhos aninhados por convenção) -8. MapFrom (alias explícito de origem) -9. Extensões de `IQueryable`/`IEnumerable` -10. Integração com SmartSearch -11. Exemplos de uso -12. Boas práticas -13. Limitações e diagnósticos -14. Instruções para Ferramentas de IA (GitHub Copilot) - -## 1. Introdução - -SmartSelector é um Roslyn Source Generator que cria automaticamente projeções fortemente tipadas para DTOs, reduzindo boilerplate em consultas LINQ/EF Core. A partir de classes parciais anotadas com atributos, o gerador produz: -- `Expression>` reutilizáveis e traduzíveis pelo EF Core. -- Métodos de fábrica `From(TFrom)` com cache de delegate. -- Métodos de extensão para `IQueryable` e `IEnumerable`. -- Propriedades simples em DTOs via `AutoProperties`. -- Flattening por convenção (nomes concatenados em PascalCase → caminhos aninhados). - -Foco: mapeamento 1x1 estilo Adapt (Mapster), sem resolvers customizados nem naming policies. - -## 2. Pacotes e responsabilidades - -- `RoyalCode.SmartSelector` (runtime): - - Tipos base e atributos: `AutoSelectAttribute`, `AutoPropertiesAttribute`, `MapFromAttribute`. - - Extensões de seleção para `IQueryable`/`IEnumerable` geradas por modelo. - -- `RoyalCode.SmartSelector.Generators` (analyzer .NET Standard 2.0): - - Gera código parcial (*.g.cs) a partir dos atributos. - - Emite diagnósticos quando o uso é incorreto. - -## 3. Conceitos centrais - -- `Model`/`Details`: DTO alvo da projeção (classe parcial). -- `TFrom`: tipo origem da projeção (entidade EF, record, classe). -- `AutoSelect`: habilita geração de expressão e extensões. -- `AutoProperties` / `AutoProperties`: gera propriedades simples automaticamente. -- Flattening: casamento por convenção de PascalCase para caminhos aninhados. -- `MapFrom`: alias explícito de origem para propriedades do DTO. - -## 4. Atributos disponíveis e regras - -### 4.1 `AutoSelect` -- Declara o tipo de origem e ativa geração de: - - `Select{TFrom}Expression` em `Model`. - - Métodos de extensão `Select{Model}` para `IQueryable`/`IEnumerable`. -- Regras: - - A classe alvo deve ser `partial`. - - Opcionalmente combine com `AutoProperties`. - -### 4.2 `AutoProperties` e `AutoProperties` -- Gera propriedades simples no DTO com base em `TFrom`. -- Quando usado sem genérico, o `TFrom` é inferido do `AutoSelect`. -- Suporta exclusão por `Exclude = [ nameof(T.Prop) ]`. - -### 4.3 `MapFromAttribute` -- Permite mapear explicitamente uma propriedade do DTO a um membro de `TFrom` por nome. -- Assinatura: - - `new MapFromAttribute(string propertyName)`. -- Preferir `nameof(TFrom.Member)` para segurança em refactors. - -## 5. Membros gerados pelo Source Generator - -Em um `Model` anotado: -- Campo de cache: `private static Func select{TFrom}Func;` -- Expressão: `public static Expression> Select{TFrom}Expression => a => new Model { ... }`. -- Fábrica: `public static Model From(TFrom a) => (selectFunc ??= SelectExpression.Compile())(a);` -- Extensões (classe estática): - - `IQueryable SelectModel(this IQueryable query)`. - - `IEnumerable SelectModel(this IEnumerable enumerable)`. - - `Model ToModel(this TFrom source)`. - -Todos visam manter consultas traduzíveis pelo EF Core. - -## 6. Propriedades suportadas em `AutoProperties` - -- Primitivos numéricos, `bool`, `string`, `char`. -- `DateTime` e nullables simples. -- `enum`, `struct`. -- `IEnumerable` onde `T` é dos tipos acima/enum/struct. -- Exclusões por `Exclude` para membros indesejados. - -## 7. Flattening (caminhos aninhados por convenção) - -- Nomes de propriedades do DTO em PascalCase mapeiam cadeias da origem: - - `CustomerAddressCity` → `a.Customer.Address.City`. - - `CustomerAddressCountryRegionName` → `a.Customer.Address.Country.Region.Name`. -- Regras: - - Segmentos devem corresponder a propriedades navegáveis. - - Último segmento é o valor terminal (simples/suportado). - - Evite colisões de prefixo; declare DTOs aninhados ou use `MapFrom`. - -## 8. MapFrom (alias explícito de origem) - -- Use quando o nome do DTO não segue convenção de flattening ou há ambiguidade. -- Exemplo: - - `CustomName` mapeado de `nameof(Product.Name)`. -- Continua traduzível pelo EF Core por estar na `Expression` gerada. - -## 9. Extensões de `IQueryable`/`IEnumerable` - -- `Select{Model}(this IQueryable)` aplica a `Expression` gerada. -- `Select{Model}(this IEnumerable)` usa `From`. -- `To{Model}(this TFrom)` cria instância única. +Este é o guia conceitual e prático. Para instruções objetivas destinadas a ferramentas de IA, consulte também [`selector.ai-rules.md`](selector.ai-rules.md). -## 10. Integração com SmartSearch +> **Verificado contra:** `RoyalCode.SmartSelector` e `RoyalCode.SmartSelector.Generators` **0.5.0** — runtime em .NET 8, .NET 9 e .NET 10; generator `netstandard2.0`, empacotado como analyzer. +> **Precedência das fontes:** documentação XML/IntelliSense da versão instalada > [`selector.ai-rules.md`](selector.ai-rules.md) > este guia. +> Se a versão do pacote for diferente, confirme as assinaturas e os diagnósticos no IDE antes de gerar código. -- Combine `SmartSelector` com `SmartSearch` para compor `Criteria` e projeção: - - `criteria.Select()` (SmartSearch) pode utilizar mapeamento por nome/caminho. - - Alternativamente, use os métodos `Select{Model}` gerados pelo SmartSelector. -- Diretrizes: - - Projeções devem ser traduzíveis (evite lógica client-side não suportada). - - Ordenação/paginação em SmartSearch funciona sobre `IQueryable` antes da projeção. +Sumário -## 11. Exemplos de uso +1. Visão geral e conceitos +2. Pacotes, namespace e instalação +3. Escolhendo o atributo +4. Início rápido +5. Membros gerados +6. Como as propriedades são mapeadas +7. Propriedades automáticas +8. Flattening +9. `MapFrom` +10. Objetos, coleções e arrays +11. `AutoDetails` +12. Política de nulabilidade +13. DTOs aninhados, herança e tipos genéricos +14. Uso com LINQ e Entity Framework Core +15. Referência dos atributos +16. Diagnósticos +17. Erros comuns +18. Boas práticas + +## 1. Visão geral e conceitos + +O SmartSelector transforma uma declaração curta: -### 11.1 Projeção simples + propriedades auto ```csharp [AutoSelect, AutoProperties] public partial class UserDetails { } +``` + +em uma projeção equivalente a: + +```csharp +Expression> expression = user => new UserDetails +{ + Id = user.Id, + Name = user.Name +}; +``` + +Além da expressão, são gerados um método `From` e extensões para `IQueryable`, `IEnumerable` e uma instância isolada. + +Conceitos principais: + +| Conceito | Papel | +|---|---| +| tipo de origem (`TFrom`) | Entidade, POCO, record ou outro tipo público do qual os valores são lidos | +| DTO de destino | Classe `partial` que recebe a projeção | +| `AutoSelect` | Gera a projeção e os métodos de consumo | +| `AutoProperties` | Gera propriedades no DTO cuja origem já foi definida por `AutoSelect` | +| `AutoProperties` | Gera somente propriedades, sem projeção nem extensões | +| `AutoDetails` | Gera ou completa o tipo de uma propriedade aninhada do DTO | +| flattening | Mapeia um caminho aninhado por concatenação de nomes, como `AddressCity` → `Address.City` | +| `MapFrom` | Define explicitamente o nome ou caminho da propriedade de origem | + +O objetivo é mapeamento declarativo 1:1. O projeto não oferece resolvers, formatters, callbacks ou políticas globais de nomes. Quando a projeção exige cálculo ou transformação de domínio, escreva uma expressão LINQ manual. + +## 2. Pacotes, namespace e instalação + +Instale o runtime e o generator: + +```xml + + + + +``` + +| Necessidade | Namespace | Pacote | +|---|---|---| +| `AutoSelect`, `AutoProperties`, `AutoDetails`, `MapFrom` | `RoyalCode.SmartSelector` | `RoyalCode.SmartSelector` | +| geração dos arquivos `.g.cs` e diagnósticos `RCSS*` | não adiciona namespace ao código consumidor | `RoyalCode.SmartSelector.Generators` | -// Uso EF Core -var list = db.Users.SelectUserDetails().ToList(); -var dto = UserDetails.From(user); -var expr = UserDetails.SelectUserExpression; +Importe no arquivo do DTO: + +```csharp +using RoyalCode.SmartSelector; ``` -### 11.2 Objeto aninhado + exclusão +O runtime suporta .NET 8, 9 e 10. O generator é distribuído como analyzer e não deve virar referência de runtime da aplicação. + +## 3. Escolhendo o atributo + +| Necessidade | Declaração recomendada | +|---|---| +| projeção com propriedades declaradas manualmente | `[AutoSelect]` | +| projeção com todas as propriedades simples compatíveis | `[AutoSelect, AutoProperties]` | +| projeção automática com exclusões ou flattening configurado | `[AutoSelect(Exclude = [...], Flattening = [...])]` | +| gerar somente a forma de um DTO, sem projeção | `[AutoProperties]` | +| renomear uma propriedade ou escolher um caminho exato | `[MapFrom(...)]` na propriedade do DTO | +| gerar a classe de uma propriedade aninhada | `[AutoDetails]` na propriedade do DTO | +| cálculo, agregação ou conversão customizada | expressão LINQ escrita manualmente | + +`AutoSelect` sozinho não gera propriedades. A exceção é quando `Exclude` ou `Flattening` aparece como argumento nomeado: essa configuração também ativa a geração automática de propriedades. + +## 4. Início rápido + +Entidade: + ```csharp -[AutoSelect, AutoProperties(Exclude = [ nameof(Book.Sku) ])] -public partial class BookDetails +public sealed class User { - public ShelfDetails Shelf { get; set; } + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string PasswordHash { get; set; } = string.Empty; } +``` + +DTO: + +```csharp +using RoyalCode.SmartSelector; + +namespace MyApp.Users; + +[AutoSelect, AutoProperties(Exclude = [nameof(User.PasswordHash)])] +public partial class UserDetails { } +``` + +Consumo: + +```csharp +// IQueryable: aplica a Expression e permite tradução pelo provider. +var users = await db.Users + .SelectUserDetails() + .ToListAsync(ct); + +// Instância isolada: usa o delegate compilado e armazenado em cache. +UserDetails details = UserDetails.From(user); +UserDetails details2 = user.ToUserDetails(); + +// Composição explícita. +Expression> selector = + UserDetails.SelectUserExpression; +``` + +## 5. Membros gerados + +Para `[AutoSelect]` em `UserDetails`, o contrato usual é: + +```csharp +public partial class UserDetails +{ + public static Expression> SelectUserExpression { get; } + public static UserDetails From(User user); +} + +public static class UserDetails_Extensions +{ + public static IQueryable SelectUserDetails( + this IQueryable query); + + public static IEnumerable SelectUserDetails( + this IEnumerable enumerable); + + public static UserDetails ToUserDetails(this User user); +} +``` + +Internamente, `From` compila `SelectUserExpression` uma vez e mantém o delegate em um campo estático. A extensão de `IQueryable` usa a expressão original; a de `IEnumerable` usa `From`. + +Os nomes são formados a partir dos nomes dos tipos: + +| Origem / destino | Membro gerado | +|---|---| +| `User` → `UserDetails` | `SelectUserExpression` | +| `IQueryable` | `SelectUserDetails()` | +| `IEnumerable` | `SelectUserDetails()` | +| `User` | `ToUserDetails()` | + +O código gerado inclui cabeçalho de arquivo, anotações nullable, documentação XML e `[GeneratedCode]`. Se um membro herdado acessível tiver o mesmo nome de um membro gerado, o generator usa `new` para tornar a ocultação explícita. + +## 6. Como as propriedades são mapeadas + +Para cada propriedade declarada no DTO, o generator tenta montar uma atribuição nesta ordem conceitual: + +1. caminho explícito de `[MapFrom("A.B")]`; +2. membro direto indicado por `[MapFrom(nameof(Entity.Member))]`; +3. membro direto com o mesmo nome da propriedade do DTO; +4. caminho por flattening do nome concatenado; +5. projeção estrutural de objeto ou coleção compatível. + +Um `MapFrom` explícito prevalece sobre uma propriedade direta homônima. Por exemplo, `[MapFrom("Address.City")]` não será substituído por uma propriedade `AddressCity` existente na raiz da entidade. + +O generator suporta atribuição direta, casts simples, casts entre enums, tratamento de `Nullable`, criação de objetos aninhados e projeção de coleções. Tipos sem correspondência ou incompatíveis produzem diagnósticos em vez de código silenciosamente incorreto. + +Propriedades públicas herdadas também participam do mapeamento. No DTO, propriedades já declaradas pelo usuário não são geradas novamente por `AutoProperties`, inclusive propriedades somente leitura. -[AutoProperties] -public partial class ShelfDetails { } +## 7. Propriedades automáticas -// Trecho gerado -// new BookDetails { -// Shelf = new ShelfDetails { Id = a.Shelf.Id, Location = a.Shelf.Location }, -// Price = a.Price, -// // Sku excluído -// } +### 7.1 Com `AutoSelect` + +```csharp +[AutoSelect, AutoProperties] +public partial class ProductDetails { } ``` -### 11.3 Flattening profundo +O tipo de origem de `AutoProperties` é inferido de `AutoSelect`. Não use `AutoProperties` na mesma classe de `AutoSelect`; essa combinação é inválida. + +### 7.2 Sem projeção + +```csharp +[AutoProperties] +public partial class ProductSnapshot { } +``` + +Essa forma gera as propriedades, mas não gera `SelectProductExpression`, `From` nem extensões. + +### 7.3 Tipos gerados automaticamente + +O filtro automático inclui: + +- `bool`, `char`, `string` e tipos numéricos primitivos; +- `decimal` e `DateTime`; +- enums e structs, inclusive value objects definidos pela aplicação; +- versões nullable suportadas; +- arrays de tipos simples, enums ou structs; +- tipos genéricos que implementam `IEnumerable` quando `T` é simples, enum ou struct. + +Classes complexas não são adicionadas automaticamente como propriedades comuns. Para elas, declare a propriedade no DTO, use `AutoDetails`, configure flattening ou escreva o DTO aninhado explicitamente. + +As propriedades de origem precisam ser públicas, de instância e legíveis. Propriedades estáticas ou sem getter público não entram na geração automática. + +## 8. Flattening + +Há dois usos relacionados, mas diferentes. + +### 8.1 Casamento por convenção de uma propriedade declarada + +O nome da propriedade do DTO representa a concatenação exata dos segmentos do caminho: + ```csharp -public class Order { public Customer Customer { get; set; } } +public sealed class Order +{ + public Customer Customer { get; set; } = new(); +} [AutoSelect] public partial class OrderDetails { - public string CustomerAddressCountryRegionName { get; set; } + public string CustomerAddressCountryRegionName { get; set; } = string.Empty; } +``` + +Trecho gerado: -// Expressão: CustomerAddressCountryRegionName = a.Customer.Address.Country.Region.Name +```csharp +CustomerAddressCountryRegionName = source.Customer.Address.Country.Region.Name ``` -### 11.4 Alias explícito com `MapFrom` +Esse casamento pode atravessar vários níveis e não exige atributo. + +### 8.2 Geração automática configurada com `Flattening` + +`Flattening` recebe nomes de propriedades complexas da raiz. Para cada propriedade indicada, o generator cria propriedades combinando o nome da raiz com seus membros terminais suportados: + +```csharp +[AutoSelect(Flattening = [nameof(Customer.Address)])] +public partial class CustomerDetails { } +``` + +Se `Address` tiver `City` e `Zip`, serão geradas `AddressCity` e `AddressZip`. A propriedade complexa `Address` não será gerada automaticamente. + +Também é válido: + +```csharp +[AutoSelect, + AutoProperties(Flattening = [nameof(Customer.Address)])] +public partial class CustomerDetails { } + +[AutoProperties(Flattening = [nameof(Customer.Address)])] +public partial class CustomerSnapshot { } +``` + +Os nomes são case-sensitive. Se o mesmo nome concatenado puder representar dois caminhos, o generator emite o warning `RCSS010`. Renomeie a propriedade ou use `MapFrom` para eliminar a ambiguidade. + +## 9. `MapFrom` + +Use `MapFrom` quando o nome do DTO não coincide com a origem ou quando quiser declarar o caminho sem depender da convenção. + +Membro direto: + ```csharp [AutoSelect] -public partial class CustomProductDetails +public partial class ProductDetails { - [MapFrom(nameof(Product.Id))] - public Guid CustomId { get; set; } - [MapFrom(nameof(Product.Name))] - public string CustomName { get; set; } + public string DisplayName { get; set; } = string.Empty; } ``` -### 11.5 Apenas propriedades automáticas +Caminho aninhado: + ```csharp -[AutoProperties] -public partial class ProductSnapshot { } +[AutoSelect] +public partial class SupplierDetails +{ + [MapFrom("Warehouse.Location")] + public string? WarehouseLocation { get; set; } +} +``` + +Regras: + +- para membro direto, prefira `nameof(Product.Name)`; +- caminhos aninhados são strings separadas por ponto; +- cada segmento deve ser uma propriedade pública legível; +- os nomes e segmentos são case-sensitive; +- caminho aninhado inexistente ou ilegível produz `RCSS017` na propriedade do DTO; +- nome direto inválido não encontra correspondência e produz `RCSS001`; +- a atribuição continua dentro da expressão, preservando a possibilidade de tradução pelo EF Core. + +O C# não permite `nameof` produzir o caminho completo com pontos. Para reduzir strings espalhadas, centralize caminhos recorrentes em constantes da aplicação. + +## 10. Objetos, coleções e arrays + +### 10.1 Objeto aninhado declarado manualmente + +```csharp +[AutoSelect] +public partial class PostDetails +{ + public string Title { get; set; } = string.Empty; + public AuthorDetails Author { get; set; } = new(); +} + +public class AuthorDetails +{ + public string Name { get; set; } = string.Empty; +} ``` -## 12. Boas práticas +O generator cria `new AuthorDetails { Name = source.Author.Name }` dentro da expressão. + +### 10.2 Coleção de objetos + +```csharp +[AutoSelect] +public partial class PostDetails +{ + public IReadOnlyList Comments { get; set; } = []; +} + +public class CommentDetails +{ + public string Content { get; set; } = string.Empty; + public string AuthorName { get; set; } = string.Empty; +} +``` + +O generator projeta cada item com `Select` e, quando o tipo de destino exige uma lista, materializa com `ToList()`. + +### 10.3 Arrays + +Arrays simples podem ser criados por `AutoProperties`: + +```csharp +public int[] Scores { get; set; } = []; +``` + +Um array de DTOs declarado manualmente também é suportado: + +```csharp +public ItemDetails[] Items { get; set; } = []; +``` + +Quando a origem é uma coleção ou array de objetos compatíveis, a expressão usa `Select(...).ToArray()`. Uma conversão implícita entre arrays compatíveis continua sendo atribuição direta. + +## 11. `AutoDetails` + +`AutoDetails` gera ou completa o tipo declarado em uma propriedade aninhada: + +```csharp +[AutoSelect, AutoProperties] +public partial class CustomerDetails +{ + [AutoDetails] + public AddressDto Address { get; set; } = new(); +} +``` + +Se `Customer.Address` for do tipo `Address`, o generator cria `AddressDto` com as propriedades simples de `Address`. O nome declarado na propriedade é a fonte da verdade; ele não precisa seguir a convenção `AddressDetails`. + +Também é possível completar um tipo existente: + +```csharp +public partial class AddressDto +{ + public string City { get; set; } = string.Empty; +} +``` + +As propriedades já declaradas não são duplicadas; as demais propriedades suportadas são geradas na outra parte da classe. + +Regras importantes: + +- `AutoDetails` só pode ser usado na propriedade de um DTO processado por `AutoProperties` ou por `AutoSelect` com geração automática de propriedades; +- se o tipo já existe no projeto, ele deve ser `partial`; +- a acessibilidade do tipo não pode ser menor que a acessibilidade da propriedade; +- duas propriedades não podem solicitar a geração do mesmo tipo; +- `Exclude` e `Flattening` também podem ser configurados em `AutoDetails`. + +## 12. Política de nulabilidade + +Com nullable reference types habilitado, a direção origem → destino determina a expressão e os diagnósticos: + +| Origem | Destino | Comportamento | +|---|---|---| +| escalar nullable | nullable | `null` flui normalmente | +| navegação nullable | objeto DTO nullable | condicional propaga `null` | +| caminho por pai nullable | escalar nullable | condicional propaga `null` | +| coleção nullable | coleção nullable | condicional propaga `null` | +| coleção nullable | coleção não nullable | coleção vazia + `RCSS016` (Info) | +| array nullable | array não nullable | `Array.Empty()` + `RCSS016` (Info) | +| escalar ou navegação nullable | destino não nullable | comportamento anterior + `RCSS015` (Warning) | + +Exemplos de emissão: + +```csharp +Warehouse = source.Warehouse == null + ? null + : new WarehouseDetails { Location = source.Warehouse.Location }; + +AddressCity = source.Address == null + ? null + : source.Address.City; + +Items = source.Items == null + ? new List() + : source.Items.Select(...).ToList(); + +ItemsArray = source.Items == null + ? Array.Empty() + : source.Items.Select(...).ToArray(); +``` + +`RCSS015` não inventa um fallback para escalares ou objetos. Ele preserva a atribuição anterior e avisa que o destino não consegue representar `null`; conforme o caminho, `From` pode receber `null` ou lançar ao navegar pelo grafo. Corrija tornando o destino nullable, alterando o modelo ou excluindo a propriedade. + +Em código nullable-oblivious (`#nullable disable`), o generator mantém o comportamento tradicional e não adiciona essa política baseada em anotações. + +Os condicionais fazem parte da árvore de expressão e os cenários de navegação nullable e `MapFrom` aninhado são exercitados pelo projeto Demo com EF Core/SQLite. + +## 13. DTOs aninhados, herança e tipos genéricos + +Um DTO pode ser declarado dentro de outro tipo: + +```csharp +public partial class Contracts +{ + [AutoSelect] + public partial class UserDetails + { + public int Id { get; set; } + } +} +``` + +O DTO e todos os tipos que o contêm devem ser `partial`. A cadeia e as acessibilidades são preservadas no código gerado. + +Restrições do destino: + +- o DTO deve estar dentro de um namespace; +- DTO genérico não é suportado; +- DTO dentro de tipo genérico não é suportado; +- um tipo contêiner não `partial` produz erro. + +O tipo de origem de `AutoSelect` pode usar sintaxe qualificada, alias `global::`, tipo aninhado ou tipo genérico construído, como `Envelope`. + +Propriedades herdadas públicas participam da projeção. O tipo parcial gerado não repete a lista de bases; ele completa a declaração existente e inicializa propriedades herdadas quando houver correspondência. + +## 14. Uso com LINQ e Entity Framework Core + +Para `IQueryable`, prefira a extensão ou a expressão: + +```csharp +var page = await db.Orders + .Where(order => order.Active) + .OrderBy(order => order.Id) + .SelectOrderDetails() + .Take(50) + .ToListAsync(ct); + +var query = db.Orders.Select(OrderDetails.SelectOrderExpression); +``` + +Isso mantém a projeção na árvore LINQ enviada ao provider. Aplique filtros e ordenações sobre a entidade antes da projeção quando eles dependem de membros que não existem no DTO. + +Para objetos já materializados: + +```csharp +OrderDetails one = OrderDetails.From(order); +OrderDetails two = order.ToOrderDetails(); +IEnumerable many = orders.SelectOrderDetails(); +``` + +`From` compila e executa a mesma expressão em memória. Ele não é uma operação assíncrona e não consulta o banco. + +Mesmo usando apenas construções normalmente traduzíveis — acesso a membros, condicionais, `Select`, `ToList` e `ToArray` — a capacidade final depende do provider e da versão do EF Core. Valide consultas relevantes com o provider real da aplicação. + +## 15. Referência dos atributos + +```csharp +[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] +public sealed class AutoSelectAttribute : AutoPropertiesAttributeBase { } + +[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] +public sealed class AutoPropertiesAttribute : AutoPropertiesAttributeBase { } + +[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] +public sealed class AutoPropertiesAttribute : AutoPropertiesAttributeBase { } + +[AttributeUsage(AttributeTargets.Property, Inherited = false, AllowMultiple = false)] +public sealed class AutoDetailsAttribute : AutoPropertiesAttributeBase { } + +[AttributeUsage(AttributeTargets.Property, Inherited = false, AllowMultiple = false)] +public sealed class MapFromAttribute : Attribute +{ + public MapFromAttribute(string propertyName) + { + PropertyName = propertyName; + } + + public string PropertyName { get; set; } +} +``` + +Configuração comum: + +```csharp +public abstract class AutoPropertiesAttributeBase : Attribute +{ + public string[]? Exclude { get; set; } + public string[]? Flattening { get; set; } +} +``` + +Os quatro atributos concretos são `sealed`. `AutoPropertiesAttributeBase` compartilha o contrato público, mas um atributo customizado derivado apenas dessa base não é reconhecido automaticamente pelo generator. + +`Exclude` e `Flattening` são case-sensitive. Quando mais de um atributo de configuração válido participa, suas listas são combinadas. + +## 16. Diagnósticos + +| ID | Severidade | Significado e correção usual | +|---|---|---| +| `RCSS000` | Error | uso inválido de `AutoSelect`, inclusive DTO ou contêiner não `partial`; corrija a declaração indicada | +| `RCSS001` | Error | não foi encontrada propriedade correspondente; alinhe o nome, use `MapFrom` ou exclua a propriedade | +| `RCSS002` | Error | os tipos correspondentes não podem ser atribuídos ou projetados; alinhe os tipos ou escreva projeção manual | +| `RCSS003` | Error | `AutoProperties` foi usado com `AutoSelect`; troque pela forma não genérica | +| `RCSS004` | Error | `AutoProperties` e `AutoProperties` foram usados juntos; mantenha apenas a forma correta | +| `RCSS005` | Error | argumento de tipo de `AutoProperties` inválido; use classe ou struct válida | +| `RCSS006` | Error | classe com `AutoProperties` não é `partial` | +| `RCSS007` | Error | `AutoProperties` não genérico está sem `AutoSelect` | +| `RCSS008` | Error | DTO ou tipo contêiner genérico não suportado | +| `RCSS010` | Warning | flattening ambíguo; use `MapFrom` ou renomeie | +| `RCSS011` | Error | DTO de `AutoSelect` está no namespace global; mova-o para um namespace | +| `RCSS012` | Error | tipo existente solicitado por `AutoDetails` não é `partial` | +| `RCSS013` | Error | mais de uma propriedade tenta gerar o mesmo tipo com `AutoDetails` | +| `RCSS014` | Error | tipo existente de `AutoDetails` tem acessibilidade insuficiente | +| `RCSS015` | Warning | origem nullable flui para destino não nullable; torne o destino nullable ou exclua a propriedade | +| `RCSS016` | Info | coleção nullable será convertida em coleção vazia no destino não nullable | +| `RCSS017` | Error | caminho aninhado de `MapFrom` inexistente ou não legível | + +Não há `RCSS009` na versão 0.5.0. + +## 17. Erros comuns + +### 17.1 Esperar propriedades de `AutoSelect` sem ativar `AutoProperties` + +Incorreto: + +```csharp +[AutoSelect] +public partial class UserDetails { } // não há propriedades para mapear +``` + +Correto: + +```csharp +[AutoSelect, AutoProperties] +public partial class UserDetails { } +``` + +Ou declare manualmente somente as propriedades desejadas. + +### 17.2 Usar a forma genérica junto com `AutoSelect` + +```csharp +[AutoSelect, AutoProperties] // RCSS003 +public partial class UserDetails { } +``` + +Use `[AutoProperties]` porque `TFrom` já vem de `AutoSelect`. + +### 17.3 Confundir flattening configurado com `MapFrom` + +`Flattening = [nameof(Customer.Address)]` gera automaticamente `AddressCity`, `AddressZip` etc. `[MapFrom("Address.City")]` escolhe o caminho de uma propriedade específica já declarada. + +### 17.4 Tornar o destino não nullable e ignorar `RCSS015` -- Prefira consumir a `Expression` gerada (`Select{TFrom}Expression`) para reutilização e composição LINQ. -- Use `nameof` em `Exclude` e `MapFrom` para segurança. -- Em DTOs com caminhos longos, avalie modelos aninhados por clareza. -- Integre com SmartSearch antes da projeção quando precisar de paginação/ordenadores. -- Evite adicionar lógica não traduzível na projeção. +O warning não garante fallback. Em uma navegação anulável, o delegate de `From` pode falhar ao percorrer o caminho. Modele o DTO conforme a realidade da origem. -## 13. Limitações e diagnósticos +### 17.5 Editar arquivos `.g.cs` -- Sem resolvers customizados ou transformações de tipo. -- Sem naming policies/renome global; use `MapFrom` para alias. -- Colisões de prefixo em flattening exigem desambiguação manual. -- Principais diagnósticos (exemplos): - - Classe não `partial` ou uso inválido (`RCSS000`). - - Propriedade não encontrada (`RCSS001`). - - Tipos incompatíveis (`RCSS002`). - - Uso incorreto de atributos (`RCSS003–RCSS005`). +Arquivos gerados são derivados dos atributos e modelos. Altere o DTO ou a entidade; qualquer edição direta será perdida na próxima geração. -## 14. Instruções para Ferramentas de IA (GitHub Copilot) +### 17.6 Usar SmartSelector para projeção calculada -Objetivo: gerar projeções seguindo o contrato da API (`AutoSelect`, `AutoProperties`, `MapFrom`) e produzir código traduzível pelo EF Core. +Não há API para soma, formatação, chamada de serviço ou resolver customizado. Escreva uma `Expression>` manual para esse cenário. -Princípios obrigatórios -- Anote DTOs com `AutoSelect` e (opcional) `AutoProperties`. -- Use `MapFrom` quando o nome do DTO não seguir a convenção ou houver ambiguidade. -- Não crie resolvers customizados; foque em mapeamento 1x1. -- Utilize métodos gerados `Select{Model}`/`To{Model}` em consultas. +### 17.7 Supor que toda expressão traduz em qualquer provider -Padrões de implementação -- DTO mínimo: - - `public partial class Dto { }` com `[AutoSelect, AutoProperties]`. -- Flattening: - - Propriedades `PascalCase` concatenadas para acessar caminhos aninhados. -- Exclusões: - - `Exclude = [ nameof(T.Prop) ]` em `AutoProperties`. -- Integração com SmartSearch: - - Aplique `criteria.FilterBy(...)`/`OrderBy(...)` sobre `IQueryable` e depois `Select{Model}`. +Os padrões do generator são orientados a LINQ/EF Core, mas tradução é responsabilidade do provider. Cubra consultas críticas com testes de integração. -Antipadrões (evitar) -- Inserir lógica não traduzível em expressões geradas. -- Criar mapeamentos com conversões complexas/formatters. -- Usar exceções como fluxo de composição; prefira expressão + métodos gerados. +## 18. Boas práticas -Exemplos de prompts corretos -- "Crie um DTO com `[AutoSelect, AutoProperties]` e projete com `db.Users.SelectUserDetails().ToList()`." -- "Declare `OrderDetails` com flattening `CustomerAddressCountryRegionName` e consuma `SelectOrderExpression`." -- "Mapeie `CustomProductDetails` usando `[MapFrom(nameof(Product.Name))]` e gere expressões traduzíveis." +- Mantenha DTO e entidades com nullable reference types habilitado. +- Use `[AutoSelect, AutoProperties]` para o caso simples e declare manualmente exceções estruturais. +- Use `Exclude` para dados sensíveis e campos internos; prefira `nameof`. +- Use `MapFrom` para renome e desambiguação; prefira `nameof` em membros diretos. +- Use `AutoDetails` quando o formato aninhado deve espelhar propriedades simples da navegação. +- Prefira DTOs aninhados explícitos a nomes de flattening excessivamente longos. +- Trate `RCSS015` como decisão de contrato, não como ruído de build. +- Projete no banco com `IQueryable`; use `From` apenas para objetos já materializados. +- Inspecione os arquivos gerados ao diagnosticar correspondência ou nulabilidade, mas não os edite. +- Teste com o provider EF Core real quando a projeção contiver objetos, coleções, arrays ou caminhos nullable. ---- -Happy coding! +Checklist antes de entregar: +- [ ] Runtime e generator estão referenciados na mesma versão. +- [ ] DTO e todos os tipos contêineres são `partial` e não genéricos. +- [ ] DTO de `AutoSelect` está em um namespace. +- [ ] A forma de `AutoProperties` corresponde ao cenário. +- [ ] Dados sensíveis foram excluídos. +- [ ] `MapFrom` aninhado usa caminho público legível e case-sensitive. +- [ ] Nulabilidade do DTO representa a nulabilidade da origem. +- [ ] Warnings `RCSS010` e `RCSS015` foram resolvidos conscientemente. +- [ ] A consulta `IQueryable` usa a expressão ou a extensão gerada. +- [ ] Projeções relevantes foram validadas com o provider real. diff --git a/RoyalCode.EnterprisePatterns/.docs/smartsearch.ai-rules.md b/RoyalCode.EnterprisePatterns/.docs/smartsearch.ai-rules.md new file mode 100644 index 0000000..dca8932 --- /dev/null +++ b/RoyalCode.EnterprisePatterns/.docs/smartsearch.ai-rules.md @@ -0,0 +1,657 @@ +# SmartSearch — Regras para IA + +Regras operacionais para gerar código com SmartSearch em projetos .NET. Para contexto conceitual e explicações, consulte [`smartsearch.md`](smartsearch.md). + +> **Verificado contra:** `RoyalCode.SmartSearch` **0.11.0** — .NET 8 / 9 / 10. +> **Precedência das fontes:** documentação XML/IntelliSense da versão instalada > este arquivo > `smartsearch.md`. +> Com versão divergente, confirme a assinatura antes de gerar código. + +## 1. Pacotes e `using` + +| Necessidade | `using` principal | Pacote | +|---|---|---| +| criteria, filtros, atributos, opções, sortings e resultados | `RoyalCode.SmartSearch` | `RoyalCode.SmartSearch.Abstractions` | +| integração e execução EF Core | `Microsoft.Extensions.DependencyInjection` | `RoyalCode.SmartSearch.EntityFramework` | +| `DbContext.Criteria()` | `Microsoft.EntityFrameworkCore` | `RoyalCode.SmartSearch.EntityFramework` | +| `ISearchManager` | `RoyalCode.SmartSearch.EntityFramework.Services` | `RoyalCode.SmartSearch.EntityFramework` | +| `ISpecifier<,>`, geradores de expressão | `RoyalCode.SmartSearch.Linq.Filtering` | `RoyalCode.SmartSearch.Linq` | +| selectors customizados | `RoyalCode.SmartSearch.Linq.Mappings` | `RoyalCode.SmartSearch.Linq` | +| `OrderByException` | `RoyalCode.SmartSearch.Exceptions` | `RoyalCode.SmartSearch.Abstractions` | +| `LIKE`/`ILIKE` PostgreSQL | `Microsoft.Extensions.DependencyInjection` | `RoyalCode.SmartSearch.EntityFramework.Npgsql` | +| helpers Minimal API | `Microsoft.AspNetCore.Routing` | `RoyalCode.SmartSearch.AspNetCore` | +| `MatchSearch<>`, `MatchList<>`, `MatchFirst<>` | `RoyalCode.SmartSearch.AspNetCore.HttpResults` | `RoyalCode.SmartSearch.AspNetCore` | +| `IHintsContainer`, `IHintPerformer` | `RoyalCode.OperationHint.Abstractions` | `RoyalCode.OperationHint.EntityFramework` | + +Para EF Core, use como referência principal: + +```xml + +``` + +Adicione `RoyalCode.SmartSearch.AspNetCore`, `RoyalCode.SmartSearch.EntityFramework.Npgsql` e `RoyalCode.OperationHint.EntityFramework` somente quando o cenário exigir. + +## 2. Regras invioláveis + +1. Crie uma nova `ICriteria` por consulta. Ela é mutável e não é thread-safe. +2. Não ramifique nem reutilize a mesma criteria para buscas independentes; filtros, sortings, limites e hints se acumulam. +3. `AsSearch()` e `Select()` desativam tracking nas opções compartilhadas. Não volte a usar a mesma criteria esperando tracking. +4. `FilterBy` recebe um objeto filtro. Nunca gere `FilterBy(x => ...)`. +5. Use propriedades nullable em filtros opcionais. `null` deve significar “critério ausente”. +6. Para `In`, declare a propriedade como `IEnumerable`, não `T[]` nem `List`. +7. Em endpoints, aplique limite explícito com `WithOptions`, `UsePages` ou `Take`. +8. Em paginação, aplique sorting estável. Sem sorting, SmartSearch tenta `Id` ascendente. +9. Use `Select()` para DTO. Não use `UseHints` esperando includes em projeção. +10. Use `UseHints` somente em terminais que materializam entidades. Hints não afetam `Exists`, contagem nem DTO. +11. Não use `IResultList.Projections` nem `GetProjection()`; a implementação padrão ainda não é funcional. +12. Configure defaults, specifiers, factories, sortings e selectors no startup, antes da primeira consulta; os pares modelo/filtro são cacheados. +13. Propague `CancellationToken` para todos os terminais async. + +## 3. Configuração canônica com EF Core + +```csharp +using Microsoft.EntityFrameworkCore; +using RoyalCode.SmartSearch; + +builder.Services.AddDbContext(options => + options.UseSqlServer(connectionString)); + +builder.Services.AddEntityFrameworkSearches(cfg => +{ + cfg.Add(); + cfg.Add(); + + cfg.AddOrderBy("createdAt", o => o.CreatedAt); + cfg.AddOrderBy("customer", o => o.Customer.Name); + + cfg.AddSelector(o => new OrderDto + { + Id = o.Id, + Number = o.Number, + CustomerName = o.Customer.Name + }); +}); +``` + +Use `cfg.Add()` quando `ICriteria` será injetada diretamente. Use `AddSearchManager()` quando todas as criterias serão criadas por manager ou `DbContext.Criteria()`. + +Formas válidas de obter uma criteria: + +```csharp +var criteria = serviceProvider.GetRequiredService>(); + +var manager = serviceProvider + .GetRequiredService>(); +var criteria2 = manager.Criteria(); + +var criteria3 = db.Criteria(); +``` + +## 4. Matriz de decisão + +| Necessidade | Gere | +|---|---| +| entidade para alteração | `CollectAsync`, `FirstOrDefaultAsync` ou `SingleAsync` diretamente na criteria | +| entidade read-only | `AsSearch().ToListAsync` | +| DTO | `Select().ToListAsync` ou `Select(expression)` | +| lista com metadados | `AsSearch().ToListAsync` / `Select().ToListAsync` | +| lista de entidades sem metadados e com tracking | `CollectAsync` | +| existência | `ExistsAsync` | +| zero ou um item | `FirstOrDefaultAsync` | +| exatamente um item | `SingleAsync` | +| paginação por página | `UsePages(itemsPerPage, pageNumber)` | +| paginação offset | `SkipTake(skip, take)` | +| opções vindas da query string | `WithOptions(searchOptions)` | +| grafo de entidade | `UseHints(...).FirstOrDefaultAsync/CollectAsync/...` | +| dados relacionados no DTO | inclua-os na expressão de `Select` | + +## 5. Filtro padrão + +Use classes simples e propriedades nullable: + +```csharp +public sealed class OrderFilter +{ + public int? Id { get; set; } + + [Criterion(CriterionOperator.Contains)] + public string? Number { get; set; } + + [Criterion("Customer.Name", CriterionOperator.Contains, + Case = CriterionCase.Insensitive)] + public string? CustomerName { get; set; } + + [Criterion("CreatedAt", CriterionOperator.GreaterThanOrEqual)] + public DateTime? CreatedAtFrom { get; set; } + + [Criterion("CreatedAt", CriterionOperator.LessThanOrEqual)] + public DateTime? CreatedAtTo { get; set; } + + [Criterion("Status", Negation = true)] + public OrderStatus? NotStatus { get; set; } +} +``` + +Uso: + +```csharp +var result = await criteria + .FilterBy(new OrderFilter + { + CustomerName = "Maria", + CreatedAtFrom = start + }) + .UsePages(20, 1) + .Select() + .ToListAsync(ct); +``` + +## 6. Operadores e valores vazios + +Operador `Auto`: + +- `string` → `Like`. +- `IEnumerable` → `In`. +- demais tipos → `Equal`. + +Escolha explícita: + +- igualdade → `Equal`. +- range inclusivo → `GreaterThanOrEqual` / `LessThanOrEqual`. +- range exclusivo → `GreaterThan` / `LessThan`. +- coleção de valores aceitos → `In`. +- pattern com `%` → `Like`. +- substring literal → `Contains`. +- prefixo/sufixo → `StartsWith` / `EndsWith`. +- negação → `Negation = true`. + +Por padrão, `IgnoreIfIsEmpty = true` ignora strings em branco, referências nulas, `Nullable` sem valor, coleções vazias e valores default. Em `byte`, `short`, `int`, `long`, `float`, `double` e `decimal` não-nullable, a guarda atual exige valor maior que zero; por isso, use nullable em filtros opcionais, principalmente para zero e valores negativos. + +Force o default somente quando necessário: + +```csharp +[Criterion(IgnoreIfIsEmpty = false)] +public bool Active { get; set; } +``` + +Ignore uma propriedade auxiliar: + +```csharp +[Criterion(Ignore = true)] +public string? UiLabel { get; set; } +``` + +## 7. `In` + +Gere exatamente este formato: + +```csharp +public sealed class StatusFilter +{ + [Criterion("Status")] + public IEnumerable? Statuses { get; set; } +} +``` + +Não gere: + +```csharp +public OrderStatus[]? Statuses { get; set; } // incorreto para a emissão atual +public List? Statuses { get; set; } // incorreto para a emissão atual +``` + +## 8. Strings e provider + +Regras: + +- `Like` interpreta `%` como curinga e, por padrão, envolve o valor em `%valor%`. +- `Contains` trata `%` como caractere literal. +- `Wrap = LikeWrap.None` usa o pattern como informado; sem curinga, o match é exato. +- `Case = CriterionCase.Insensitive` normaliza ambos os lados no modo portável. +- `Case = Default` ou `Sensitive` não força comparação case-sensitive; a collation ainda decide. + +Exemplos: + +```csharp +[Criterion(CriterionOperator.Like, Wrap = LikeWrap.None)] +public string? SkuPattern { get; set; } // "ABC%" + +[Criterion(CriterionOperator.Contains, Case = CriterionCase.Insensitive)] +public string? Name { get; set; } +``` + +Defaults globais: + +```csharp +CriterionDefaults.DefaultStringOperator = CriterionOperator.Contains; +CriterionDefaults.WrapLikeValue = false; +``` + +Defina-os antes de qualquer busca. + +Para EF relacional: + +```csharp +builder.Services.AddEntityFrameworkLikeOperator(); +``` + +Para PostgreSQL: + +```csharp +builder.Services.AddNpgsqlLikeOperators(); +``` + +Chame `AddNpgsqlLikeOperators()` no lugar de registrar primeiro `AddEntityFrameworkLikeOperator()`, porque a primeira factory aplicável vence. O modo portável suporta `%`, não `_`, e aproxima patterns com mais de cinco fatiamentos. Use factory nativa quando precisar da semântica completa do provider. + +## 9. OR e filtros complexos + +Use `[Disjunction]` quando propriedades diferentes têm valores independentes: + +```csharp +public sealed class ProductFilter +{ + [Disjunction("text")] + [Criterion("Name", CriterionOperator.Contains)] + public string? TextInName { get; set; } + + [Disjunction("text")] + [Criterion("Sku", CriterionOperator.Contains)] + public string? TextInSku { get; set; } +} +``` + +Use `Or` no nome/caminho quando o mesmo valor deve testar vários membros: + +```csharp +public string? NameOrEmail { get; set; } + +[Criterion(TargetPropertyPath = "FirstNameOrLastName")] +public string? PersonName { get; set; } +``` + +Desative a inferência quando `Or` for parte do nome: + +```csharp +[Criterion("Number", DisableOrFromName = true)] +public string? NumberOrCode { get; set; } +``` + +Filtro complexo: + +```csharp +[ComplexFilter] +public sealed class AddressFilter +{ + public string? City { get; set; } + public string? State { get; set; } +} + +public sealed class CustomerFilter +{ + [Criterion("MainAddress")] + public AddressFilter? Address { get; set; } +} +``` + +Campos internos preenchidos são AND. Objeto nulo ou totalmente vazio não filtra. + +## 10. Customização e precedência + +Escolha nesta ordem de simplicidade: + +1. `[Criterion]`. +2. `ConfigureSpecifierGenerator(...).For(...).Predicate(...)` para uma propriedade. +3. `[FilterExpressionGenerator]` para uma propriedade que exige árvore de expressão customizada. +4. `AddSpecifier` ou `ISpecifier<,>` para controlar o filtro inteiro. +5. Método `Filter(IQueryable)` no filtro quando esse estilo já for adotado pelo projeto. + +Predicate por propriedade: + +```csharp +public sealed class OrderProductFilter +{ + public int? ProductId { get; set; } +} + +cfg.ConfigureSpecifierGenerator(options => +{ + options.For(f => f.ProductId) + .Predicate(productId => order => + order.Items.Any(item => item.ProductId == productId)); +}); +``` + +Specifier completo: + +```csharp +cfg.AddSpecifier((query, filter) => +{ + if (!string.IsNullOrWhiteSpace(filter.Text)) + query = query.Where(o => o.Number.Contains(filter.Text)); + + return query; +}); +``` + +Precedência real por `(modelo, filtro)`: + +1. specifier registrado ou cacheado; +2. `ISpecifier` de DI; +3. método público com parâmetro/retorno `IQueryable`; +4. gerador declarativo. + +Um specifier completo ou método no filtro substitui o processamento convencional de todas as propriedades. + +Um `[FilterExpressionGenerator]` também é responsável por tratar valores vazios da sua propriedade; a guarda de `IgnoreIfIsEmpty` não é adicionada automaticamente nesse caminho. + +## 11. Sorting + +Prefira nomes registrados para contratos públicos: + +```csharp +cfg.AddOrderBy("createdAt", o => o.CreatedAt); +cfg.AddOrderBy("customer", o => o.Customer.Name); +``` + +Aplicação: + +```csharp +criteria.OrderBy(new Sorting +{ + OrderBy = "createdAt", + Direction = ListSortDirection.Descending +}); +``` + +Formatos de `Sorting.TryParse`: `Name`, `Name asc`, `Name desc`, `Name-asc`, `Name-desc` ou JSON. + +Regras: + +- vários sortings são aplicados na ordem recebida; +- propriedade inválida lança `OrderByNotSupportedException`, que é um `OrderByException`; +- falha de tradução de sorting pelo provider também vira `OrderByException`; +- paginação/limite sem sorting adiciona `Id` ascendente; +- se não houver `Id`, gere sorting explícito antes do limite; +- em borda HTTP manual, capture `OrderByException` e converta para erro de parâmetro `orderby`. + +## 12. Paginação, contagem e resultados + +Página: + +```csharp +var result = await criteria + .UsePages(itemsPerPage: 20, pageNumber: 1) + .AsSearch() + .ToListAsync(ct); +``` + +Offset: + +```csharp +var result = await criteria + .SkipTake(skip, take) + .UseCount(false) + .AsSearch() + .ToListAsync(ct); +``` + +Query string: + +```csharp +var result = await criteria + .WithOptions(options) + .FilterBy(filter) + .Select() + .ToListAsync(ct); +``` + +Regras: + +- `WithOptions(new SearchOptions())` aplica página 1, 10 itens. +- `AsSearch().ToList()` sem `WithOptions`/limites retorna todos os itens; não há limite 10 implícito. +- quando `Page > 0`, `Skip`/`Take` são ignorados em favor da paginação. +- `UseCount(false)` retorna `Count = 0` e `Pages = 0`. +- `UseLastCount(n)` reutiliza somente total positivo. +- `Pages` usa `Ceiling`. +- `Collect` retorna itens, não metadados. +- `ToAsyncListAsync` retorna `IAsyncEnumerable` em `Items`. +- não use `Projections`/`GetProjection()`. + +Metadados válidos: + +```csharp +result.Items; +result.Count; +result.Page; +result.Pages; +result.ItemsPerPage; +result.Skipped; +result.Taken; +result.Sortings; +``` + +## 13. Tracking e terminais + +| Chamada | Tracking | Hints | +|---|---|---| +| `criteria.Collect[Async]()` | sim | sim | +| `criteria.FirstOrDefault[Async]()` | sim | sim | +| `criteria.Single[Async]()` | sim | sim | +| `criteria.Exists[Async]()` | não materializa | não | +| `criteria.AsSearch().ToList[Async]()` | não | sim | +| `criteria.Select().ToList[Async]()` | não | não | + +Use `FirstOrDefault` para ausência esperada. Use `Single` apenas quando a cardinalidade exata for parte do contrato; ele lança se houver zero ou mais de um item. + +Nunca gere ramificação assim: + +```csharp +var search = criteria.AsSearch(); +var entities = criteria.Collect(); // também ficou no-tracking +``` + +Resolva outra criteria. + +## 14. Selectors e DTOs + +Preferência: + +1. selector registrado para contrato estável ou projeção complexa; +2. expressão inline para projeção local; +3. `Select()` por convenção apenas quando o mapeamento for simples e verificado. + +Registrado: + +```csharp +cfg.AddSelector(o => new OrderDto +{ + Id = o.Id, + Number = o.Number, + CustomerName = o.Customer.Name, + Total = o.Items.Sum(i => i.Quantity * i.UnitPrice) +}); +``` + +Inline: + +```csharp +var dto = await criteria + .Select(o => new OrderDto + { + Id = o.Id, + Number = o.Number + }) + .FirstOrDefaultAsync(ct); +``` + +Não adicione `Include`/hints para satisfazer DTO. O provider traduz a projeção diretamente. + +## 15. Operation Hints + +Registre handlers uma vez: + +```csharp +builder.Services.ConfigureOperationHints(registry => +{ + registry.AddIncludesHandler((hint, includes) => + { + if (hint is OrderHints.WithCustomer) + includes.IncludeReference(o => o.Customer); + + if (hint is OrderHints.WithItems) + includes.IncludeCollection(o => o.Items); + }); +}); +``` + +Use localmente: + +```csharp +var order = await criteria + .UseHints(OrderHints.WithCustomer, OrderHints.WithItems) + .FilterBy(new OrderByIdFilter { Id = id }) + .FirstOrDefaultAsync(ct); +``` + +Regras: + +- `UseHints` requer ao menos um enum; +- `null` lança `ArgumentNullException`; +- array vazio lança `ArgumentException`; +- hints locais não vazam para outra criteria; +- hints ambientes e locais são combinados; +- sem Operation Hint registrado, o comportamento é no-op; +- hints não se aplicam a `Exists`, count ou DTO. + +## 16. ASP.NET Core + +Mapeamento canônico: + +```csharp +var group = app.MapGroup("/api"); + +group.MapSearch("/orders"); +group.MapList("/products"); +group.MapFirst("/products/first"); +group.MapSelectFirst("/customers/first"); +``` + +Escolha: + +- `MapSearch` → `IResultList` com filtro, sorting, paginação e count. +- `MapList` → `IReadOnlyList` com filtro e sorting. +- `MapFirst` → primeira entidade. +- `MapSelectFirst` → primeiro DTO. + +Status gerados pelos helpers: + +- 200 com resultado; +- 204 sem itens; +- 400 para sorting inválido; +- 500 para erro inesperado. + +Em endpoints manuais, anote obrigatoriamente o filtro e `SearchOptions` com `[AsParameters]`. Prefira também `[FromQuery]` para `Sorting[]?`, `[FromServices]` para `ICriteria` e `[FromRoute]` para identificadores de rota, deixando explícita a origem de cada parâmetro. + +Rota com escopo adicional: + +```csharp +group.MapSearch( + "/customers/{customerId:int}/orders", + (customerId, criteria) => + { + criteria.FilterBy(new OrdersByCustomerFilter + { + CustomerId = customerId + }); + }); +``` + +Não retorne uma nova criteria no delegate; os métodos mutam a instância recebida. + +## 17. Receita completa + +```csharp +using Microsoft.AspNetCore.Mvc; +using RoyalCode.SmartSearch; + +app.MapGet("/orders", async ( + [AsParameters] OrderFilter filter, + [AsParameters] SearchOptions options, + [FromQuery] Sorting[]? orderby, + [FromServices] ICriteria criteria, + CancellationToken ct) + => await criteria + .WithOptions(options) + .OrderBy(orderby) + .FilterBy(filter) + .Select() + .ToListAsync(ct)); +``` + +Para retornar uma entidade rastreada: + +```csharp +app.MapGet("/orders/{id:int}/edit", async ( + [FromRoute] int id, + [FromServices] ICriteria criteria, + CancellationToken ct) + => await criteria + .UseHints(OrderHints.WithItems) + .FilterBy(new OrderByIdFilter { Id = id }) + .FirstOrDefaultAsync(ct)); +``` + +Forma equivalente em um método de endpoint: + +```csharp +public static async Task> SearchOrdersAsync( + [AsParameters] OrderFilter filter, + [AsParameters] SearchOptions options, + [FromQuery] Sorting[]? orderby, + [FromServices] ICriteria criteria, + CancellationToken ct) +{ + return await criteria + .WithOptions(options) + .OrderBy(orderby) + .FilterBy(filter) + .Select() + .ToListAsync(ct); +} +``` + +## 18. Anti-padrões + +- Não gere `FilterBy(entity => condition)`. +- Não reutilize `ICriteria` entre consultas. +- Não execute a mesma criteria em paralelo. +- Não chame `AsSearch()` e depois espere tracking na criteria original. +- Não use propriedades não-nullable para critérios opcionais sem avaliar `IgnoreIfIsEmpty`. +- Não declare `In` como array ou `List`. +- Não use método manual no filtro esperando que as propriedades também sejam processadas. +- Não altere `CriterionDefaults` depois da primeira busca. +- Não use `UseHints` para DTO, `Exists` ou count. +- Não espalhe lógica de `Include` pelos call sites; registre hints quando o retorno for entidade. +- Não exponha nomes internos frágeis como sortings públicos; registre aliases. +- Não pagine sem sorting estável. +- Não assuma limite de 10 em `AsSearch()` sem `WithOptions`. +- Não use `GetProjection()`. +- Não engula `OrderByException` como erro interno em endpoints manuais. +- Não omita `CancellationToken` em código async de aplicação. + +## 19. Checklist antes de entregar o código + +- [ ] Pacote e namespace conferidos na tabela da §1. +- [ ] Uma nova `ICriteria` é usada por consulta. +- [ ] Filtro é um objeto; não há lambda em `FilterBy`. +- [ ] Critérios opcionais usam tipos nullable. +- [ ] Propriedade `In` está declarada como `IEnumerable`. +- [ ] `Like` versus `Contains`, wrap e case foram escolhidos conscientemente. +- [ ] OR usa `[Disjunction]` ou token `Or` conforme a semântica desejada. +- [ ] Consulta paginada tem limite e sorting estável. +- [ ] DTO usa selector registrado, expressão inline ou convenção verificada. +- [ ] Tracking foi preservado apenas quando necessário. +- [ ] Hints aparecem somente em terminais de entidade. +- [ ] `OrderByException` é tratado na borda HTTP manual. +- [ ] Não há uso de `Projections`/`GetProjection()`. +- [ ] Configurações globais acontecem antes da primeira consulta. +- [ ] Todo terminal async recebe `CancellationToken`. diff --git a/RoyalCode.EnterprisePatterns/.docs/smartsearch.md b/RoyalCode.EnterprisePatterns/.docs/smartsearch.md new file mode 100644 index 0000000..b5fed6d --- /dev/null +++ b/RoyalCode.EnterprisePatterns/.docs/smartsearch.md @@ -0,0 +1,1117 @@ +# Documentação da API SmartSearch + +SmartSearch é um conjunto de bibliotecas .NET para construir consultas com filtros declarativos, ordenação dinâmica, paginação, projeção para DTO e execução sobre Entity Framework Core. + +Este é o guia conceitual e prático. Para instruções objetivas destinadas a ferramentas de IA, consulte também [`smartsearch.ai-rules.md`](smartsearch.ai-rules.md). + +> **Verificado contra:** `RoyalCode.SmartSearch` **0.11.0** — .NET 8, .NET 9 e .NET 10. +> **Precedência das fontes:** documentação XML/IntelliSense da versão instalada > `smartsearch.ai-rules.md` > este guia. +> Se a versão do pacote for diferente, confirme as assinaturas no IDE antes de gerar código. + +Sumário + +1. Visão geral e conceitos +2. Pacotes, namespaces e instalação +3. Configuração com Entity Framework Core +4. Escolhendo o fluxo de consulta +5. Filtros declarativos +6. Strings: `Like`, `Contains` e case-insensitive +7. AND, OR e filtros complexos +8. Customização de specifiers e expressões +9. Ordenação +10. Paginação, limites e `SearchOptions` +11. Projeção para DTO +12. Terminais, tracking e resultados +13. Operation Hints e carregamento de agregados +14. Helpers para ASP.NET Core +15. Referência rápida da API +16. Erros comuns +17. Boas práticas + +## 1. Visão geral e conceitos + +SmartSearch implementa o padrão **Filter-Specifier** sobre `IQueryable`: + +- **Filter:** objeto que transporta os valores da busca. +- **Criterion:** regra que liga uma propriedade do filtro a uma propriedade do modelo. +- **Specifier:** componente que transforma o filtro em operações sobre `IQueryable`. +- **Criteria:** builder mutável que acumula filtros, sortings, limites e hints. +- **Search:** modo de leitura sem tracking, com suporte a `IResultList`. +- **Selector:** expressão que projeta uma entidade para um DTO. +- **Sorting:** descrição dinâmica de uma ordenação. + +Fluxo geral: + +```text +filtro + opções + sortings + │ + ▼ + ICriteria + │ + specifiers LINQ + │ + ▼ + IQueryable + ├─ Collect / First / Single ──► entidade com tracking + ├─ AsSearch ──────────────────► entidade sem tracking + metadados + └─ Select ──────────────► DTO sem tracking + metadados +``` + +`ICriteria` é **mutável**. Cada chamada acrescenta ou altera opções na mesma instância. Use uma nova criteria por consulta e não execute em paralelo nem ramifique a mesma instância para montar buscas independentes. + +## 2. Pacotes, namespaces e instalação + +Para o cenário comum com EF Core, instale: + +```bash +dotnet add package RoyalCode.SmartSearch.EntityFramework +``` + +Pacotes e responsabilidades: + +| Pacote | Responsabilidade | +|---|---| +| `RoyalCode.SmartSearch.Abstractions` | `ICriteria<>`, `ISearch<>`, atributos, sortings e result lists | +| `RoyalCode.SmartSearch.Core` | implementações padrão de criteria/search e pipeline abstrato | +| `RoyalCode.SmartSearch.Linq` | geração de specifiers, expressões, selectors e order-by | +| `RoyalCode.SmartSearch.EntityFramework` | DI, execução EF Core, tracking e `DbContext.Criteria()` | +| `RoyalCode.SmartSearch.EntityFramework.Npgsql` | emissão PostgreSQL de `LIKE`/`ILIKE` | +| `RoyalCode.SmartSearch.AspNetCore` | helpers de Minimal API e resultados HTTP padronizados | + +Namespaces que mais causam dúvida: + +| Tipo ou método | `using` | Pacote | +|---|---|---| +| `ICriteria<>`, `ISearch<>`, `SearchOptions`, `Sorting`, atributos | `RoyalCode.SmartSearch` | `RoyalCode.SmartSearch.Abstractions` | +| `ISearchManager` | `RoyalCode.SmartSearch.EntityFramework.Services` | `RoyalCode.SmartSearch.EntityFramework` | +| `AddEntityFrameworkSearches`, `AddEntityFrameworkLikeOperator` | `Microsoft.Extensions.DependencyInjection` | `RoyalCode.SmartSearch.EntityFramework` | +| `DbContext.Criteria()` | `Microsoft.EntityFrameworkCore` | `RoyalCode.SmartSearch.EntityFramework` | +| `ISpecifier<,>`, `ISpecifierExpressionGenerator` | `RoyalCode.SmartSearch.Linq.Filtering` | `RoyalCode.SmartSearch.Linq` | +| `ISelector<,>` | `RoyalCode.SmartSearch.Linq.Mappings` | `RoyalCode.SmartSearch.Linq` | +| `OrderByException` | `RoyalCode.SmartSearch.Exceptions` | `RoyalCode.SmartSearch.Abstractions` | +| `AddNpgsqlLikeOperators` | `Microsoft.Extensions.DependencyInjection` | `RoyalCode.SmartSearch.EntityFramework.Npgsql` | +| `MapSearch`, `MapList`, `MapFirst`, `MapSelectFirst` | `Microsoft.AspNetCore.Routing` | `RoyalCode.SmartSearch.AspNetCore` | +| `MatchSearch<>`, `MatchList<>`, `MatchFirst<>` | `RoyalCode.SmartSearch.AspNetCore.HttpResults` | `RoyalCode.SmartSearch.AspNetCore` | +| `IHintsContainer`, `IHintPerformer` | `RoyalCode.OperationHint.Abstractions` | `RoyalCode.OperationHint.EntityFramework` | + +Os pacotes de nível superior trazem suas dependências SmartSearch transitivamente. Em aplicações, normalmente basta referenciar `EntityFramework`, mais `AspNetCore` e/ou `EntityFramework.Npgsql` quando necessários. + +## 3. Configuração com Entity Framework Core + +Registre o `DbContext`, as entidades pesquisáveis e as configurações globais no startup: + +```csharp +using Microsoft.EntityFrameworkCore; +using RoyalCode.SmartSearch; + +builder.Services.AddDbContext(options => + options.UseSqlServer(connectionString)); + +builder.Services.AddEntityFrameworkSearches(cfg => +{ + cfg.Add(); + cfg.Add(); + + cfg.AddOrderBy("createdAt", o => o.CreatedAt); + cfg.AddOrderBy("customer", o => o.Customer.Name); + + cfg.AddSelector(o => new OrderDto + { + Id = o.Id, + Number = o.Number, + CustomerName = o.Customer.Name + }); +}); +``` + +`cfg.Add()` registra `ICriteria` como serviço transient. Também é possível registrar tipos descobertos dinamicamente: + +```csharp +builder.Services.AddEntityFrameworkSearches(cfg => +{ + foreach (var entityType in discoveredEntityTypes) + cfg.Add(entityType); +}); +``` + +Há três formas usuais de obter uma criteria: + +```csharp +// Entidade registrada com cfg.Add() +var criteria = serviceProvider.GetRequiredService>(); + +// Qualquer entidade do DbContext, pelo manager +var manager = serviceProvider + .GetRequiredService>(); +var criteria2 = manager.Criteria(); + +// Extensão sobre um DbContext configurado no container +var criteria3 = db.Criteria(); +``` + +`AddSearchManager()` pode ser usado sem `cfg.Add()` quando a aplicação sempre cria criterias pelo manager ou por `DbContext.Criteria()`: + +```csharp +builder.Services.AddSearchManager(); +``` + +## 4. Escolhendo o fluxo de consulta + +Use esta matriz como ponto de partida: + +| Necessidade | Fluxo recomendado | +|---|---| +| editar entidades após consultar | `criteria.Collect()` / `FirstOrDefault()` / `Single()` | +| leitura de entidades sem tracking | `criteria.AsSearch().ToList()` | +| leitura de DTOs | `criteria.Select().ToList()` | +| lista com paginação e metadados | `UsePages(...).AsSearch().ToList()` ou `Select().ToList()` | +| lista simples sem metadados | `Collect()` para entidades; `AsSearch().ToList().Items` para no-tracking | +| verificar existência | `Exists()` / `ExistsAsync()` | +| primeiro item opcional | `FirstOrDefault()` / `FirstOrDefaultAsync()` | +| exatamente um item | `Single()` / `SingleAsync()` | +| carregar grafo de entidade | `UseHints(...)` antes de um terminal de entidade | +| projetar dados relacionados | `Select()`; não use hints para DTO | + +Exemplo canônico de leitura paginada: + +```csharp +var result = await criteria + .FilterBy(new OrderFilter { CustomerName = "Maria" }) + .OrderBy(new Sorting { OrderBy = "createdAt", Direction = ListSortDirection.Descending }) + .UsePages(itemsPerPage: 20, pageNumber: 1) + .Select() + .ToListAsync(ct); +``` + +## 5. Filtros declarativos + +### 5.1 Convenção básica + +Toda propriedade pública do filtro é considerada um critério. Sem atributo, o SmartSearch procura no modelo uma propriedade com o mesmo nome e escolhe o operador automaticamente. + +```csharp +public sealed class OrderFilter +{ + public int? Id { get; set; } + + public string? Number { get; set; } + + [Criterion("Customer.Name")] + public string? CustomerName { get; set; } +} +``` + +Uso: + +```csharp +var orders = await criteria + .FilterBy(new OrderFilter { CustomerName = "Maria" }) + .CollectAsync(ct); +``` + +`FilterBy` recebe um **objeto filtro**, não uma expressão `Expression>`. + +### 5.2 Operadores automáticos + +Quando `CriterionAttribute.Operator` é `Auto`: + +| Tipo da propriedade do filtro | Operador | +|---|---| +| `string` | `Like` por padrão | +| `IEnumerable` | `In` | +| demais tipos | `Equal` | + +Operadores disponíveis: + +| Operador | Semântica | +|---|---| +| `Equal` | igualdade | +| `GreaterThan` | maior que | +| `GreaterThanOrEqual` | maior ou igual | +| `LessThan` | menor que | +| `LessThanOrEqual` | menor ou igual | +| `In` | valor do modelo pertence à coleção do filtro | +| `Like` | pattern com `%`, com wrap configurável | +| `Contains` | substring literal | +| `StartsWith` | prefixo | +| `EndsWith` | sufixo | + +### 5.3 Caminho alvo, range e negação + +```csharp +public sealed class OrderFilter +{ + [Criterion("CreatedAt", CriterionOperator.GreaterThanOrEqual)] + public DateTime? CreatedAtFrom { get; set; } + + [Criterion("CreatedAt", CriterionOperator.LessThanOrEqual)] + public DateTime? CreatedAtTo { get; set; } + + [Criterion("Status", Negation = true)] + public OrderStatus? NotStatus { get; set; } + + [Criterion("Customer.Email", CriterionOperator.Equal)] + public string? CustomerEmail { get; set; } +} +``` + +O caminho pode ser passado pelo construtor ou pela propriedade `TargetPropertyPath`: + +```csharp +[Criterion(TargetPropertyPath = "Customer.Email", Operator = CriterionOperator.Equal)] +public string? Email { get; set; } +``` + +Propriedades principais de `[Criterion]`: + +| Propriedade | Uso | +|---|---| +| `Operator` | escolhe o operador | +| `TargetPropertyPath` | redireciona para propriedade simples ou aninhada | +| `Negation` | nega a condição | +| `Ignore` | exclui a propriedade do filtro | +| `IgnoreIfIsEmpty` | ignora valores vazios; padrão `true` | +| `Case` | sensibilidade a maiúsculas/minúsculas em operadores de string | +| `Wrap` | controla `%valor%` em `Like` | +| `DisableOrFromName` | impede inferência de OR a partir do nome/caminho | + +`[Criterion]` sem configurações é equivalente à convenção sem atributo. + +### 5.4 Valores vazios + +Por padrão, `IgnoreIfIsEmpty = true`. São ignorados, entre outros: + +- `null` em referências e `Nullable`; +- string nula, vazia ou apenas com espaços; +- coleção vazia; +- valores default de structs, como `Guid.Empty`; +- em `byte`, `short`, `int`, `long`, `float`, `double` e `decimal` não-nullable, a guarda atual exige valor maior que zero; portanto zero e negativos não filtram. + +Para filtros opcionais, prefira `int?`, `decimal?`, `bool?`, enums nullable e datas nullable. Assim, `null` significa “não filtrar” e valores como `0`, `false` ou o primeiro enum continuam sendo critérios válidos. + +Use `IgnoreIfIsEmpty = false` somente quando o valor default realmente precisar gerar condição: + +```csharp +[Criterion(IgnoreIfIsEmpty = false)] +public bool Active { get; set; } +``` + +### 5.5 Operador `In` + +Declare a propriedade do filtro como `IEnumerable`: + +```csharp +public sealed class OrderStatusesFilter +{ + [Criterion("Status")] + public IEnumerable? Statuses { get; set; } +} + +var orders = await criteria + .FilterBy(new OrderStatusesFilter + { + Statuses = new[] { OrderStatus.Paid, OrderStatus.Shipped } + }) + .CollectAsync(ct); +``` + +Na implementação atual, a emissão de `In` exige que o tipo declarado seja exatamente `IEnumerable`. Não declare a propriedade como array ou `List`. + +## 6. Strings: `Like`, `Contains` e case-insensitive + +### 6.1 `Like` e `Contains` + +| Operador | Valor `jo%o` | Comportamento | +|---|---|---| +| `Like` | `%jo%o%` por padrão | `%` é curinga; o valor recebe wrap por padrão | +| `Contains` | `jo%o` literal | `%` é texto comum | + +O operador automático de strings é `Like`. Sem curingas informados pelo usuário, o wrap padrão faz a busca se comportar como substring. + +```csharp +public sealed class ProductFilter +{ + // Pattern como informado: "ABC%" funciona como prefixo. + [Criterion(CriterionOperator.Like, Wrap = LikeWrap.None)] + public string? Sku { get; set; } + + // Substring literal, sem interpretar %. + [Criterion(CriterionOperator.Contains)] + public string? Description { get; set; } +} +``` + +Defaults globais devem ser configurados no startup, antes da primeira busca: + +```csharp +CriterionDefaults.DefaultStringOperator = CriterionOperator.Contains; +CriterionDefaults.WrapLikeValue = false; +``` + +Os specifiers gerados são cacheados por par `(modelo, filtro)`. Alterar defaults depois que um par já foi usado não regenera seu specifier. + +### 6.2 Case-insensitive + +```csharp +[Criterion(CriterionOperator.Contains, Case = CriterionCase.Insensitive)] +public string? Name { get; set; } +``` + +`CriterionCase.Insensitive` vale para `Like`, `Contains`, `StartsWith` e `EndsWith`. A emissão portável normaliza ambos os lados com `ToUpper()`. `Default` e `Sensitive` não normalizam; o resultado efetivo ainda depende da collation do provider. + +Normalização por função costuma impedir o uso de índices comuns. Quando o schema é controlado, considere collation ou tipo de coluna apropriado no banco. + +### 6.3 Emissão portável e emissão do provider + +Sem configuração adicional, `Like` usa uma expressão portável com `StartsWith`, `EndsWith`, `Contains`, `IndexOf` e `Substring`. + +Limitações do modo portável: + +- suporta `%`, mas não `_` como curinga; +- depois de cinco fatiamentos, segmentos excedentes usam `Contains` sem garantia de ordem; +- a tradução final depende das capacidades do provider LINQ. + +Para SQL relacional com EF Core: + +```csharp +builder.Services.AddEntityFrameworkLikeOperator(); +``` + +Isso emite `EF.Functions.Like`, honrando `%` e `_` conforme o provider. + +Para PostgreSQL: + +```csharp +builder.Services.AddNpgsqlLikeOperators(); +``` + +O pacote Npgsql emite `EF.Functions.ILike` quando `Case = Insensitive` e `EF.Functions.Like` nos demais casos. Chame `AddNpgsqlLikeOperators()` no lugar de registrar primeiro `AddEntityFrameworkLikeOperator()`: a ordem importa, pois a primeira factory que produzir uma expressão vence. + +Factories só afetam critérios gerados. Predicados manuais, specifiers registrados e métodos de filtro são responsabilidade do consumidor. + +## 7. AND, OR e filtros complexos + +### 7.1 AND por padrão + +Propriedades comuns de um filtro e chamadas sucessivas de `FilterBy` são acumuladas na mesma consulta: + +```csharp +criteria + .FilterBy(new TenantFilter { TenantId = tenantId }) + .FilterBy(new OrderFilter { Status = OrderStatus.Paid }); +``` + +### 7.2 OR agrupado com `[Disjunction]` + +Propriedades com o mesmo alias são unidas por OR: + +```csharp +public sealed class ProductFilter +{ + [Disjunction("text")] + [Criterion("Name", CriterionOperator.Contains)] + public string? TextInName { get; set; } + + [Disjunction("text")] + [Criterion("Sku", CriterionOperator.Contains)] + public string? TextInSku { get; set; } +} +``` + +Se todos os membros do grupo estiverem vazios, nenhum `Where` é aplicado. Se apenas um tiver valor, há uma única condição. Com vários valores, as condições são combinadas por OR. + +### 7.3 OR inferido pelo nome ou caminho + +O token `Or` em nome ou `TargetPropertyPath` cria uma disjunção usando o mesmo valor: + +```csharp +public sealed class CustomerFilter +{ + // Customer.Name LIKE value OR Customer.Email LIKE value + public string? NameOrEmail { get; set; } + + [Criterion(TargetPropertyPath = "FirstNameOrLastName")] + public string? PersonName { get; set; } +} +``` + +Se `Or` fizer parte do nome e não representar uma disjunção, desative a convenção: + +```csharp +[Criterion("Number", DisableOrFromName = true)] +public string? NumberOrCode { get; set; } +``` + +### 7.4 Filtros complexos + +Use `[ComplexFilter]` quando uma propriedade do filtro contém um subfiltro aplicado a um objeto complexo do modelo: + +```csharp +[ComplexFilter] +public sealed class AddressFilter +{ + public string? City { get; set; } + public string? State { get; set; } +} + +public sealed class CustomerFilter +{ + [Criterion("MainAddress")] + public AddressFilter? Address { get; set; } +} +``` + +Os campos internos preenchidos são combinados por AND. Se o objeto for nulo ou todos os seus campos forem vazios, o filtro complexo não aplica condições. + +O atributo pode ser colocado no tipo complexo ou diretamente na propriedade. Dentro do subfiltro, continuam válidos `[Criterion]`, caminhos, operadores e OR por nome. + +## 8. Customização de specifiers e expressões + +Quando convenções e atributos não forem suficientes, escolha o ponto de extensão mais simples que resolva o caso. + +### 8.1 Predicate por propriedade + +Configure uma propriedade específica sem abandonar o restante do filtro declarativo: + +```csharp +public sealed class OrderProductFilter +{ + public int? ProductId { get; set; } +} + +builder.Services.AddEntityFrameworkSearches(cfg => +{ + cfg.Add(); + + cfg.ConfigureSpecifierGenerator(options => + { + options.For(f => f.ProductId) + .Predicate(productId => order => + order.Items.Any(item => item.ProductId == productId)); + }); +}); +``` + +O predicate substitui a resolução convencional apenas dessa propriedade. A regra de valor vazio ainda é aplicada. + +### 8.2 Specifier registrado + +Registre a função completa para o par modelo/filtro: + +```csharp +cfg.AddSpecifier((query, filter) => +{ + if (!string.IsNullOrWhiteSpace(filter.Text)) + { + query = query.Where(o => + o.Number.Contains(filter.Text) || + o.Customer.Name.Contains(filter.Text)); + } + + return query; +}); +``` + +Também é possível implementar e registrar `ISpecifier`. + +### 8.3 Método no próprio filtro + +Um filtro pode declarar um método público com um parâmetro e retorno `IQueryable`: + +```csharp +public sealed class OrderTextFilter +{ + public string? Text { get; set; } + + public IQueryable Filter(IQueryable query) + { + if (!string.IsNullOrWhiteSpace(Text)) + query = query.Where(o => o.Number.Contains(Text)); + + return query; + } +} +``` + +O nome `Filter` é recomendado, embora a descoberta use a assinatura. Esse método representa o filtro inteiro; suas propriedades não são processadas novamente por convenção. + +### 8.4 Gerador de expressão por atributo + +Use `[FilterExpressionGenerator]` para manter o filtro declarativo e gerar uma expressão especial: + +```csharp +public sealed class OrderFilter +{ + [Criterion("CreatedAt")] + [FilterExpressionGenerator] + public Period Period { get; set; } +} + +public sealed class PeriodExpressionGenerator : ISpecifierExpressionGenerator +{ + public static Expression GenerateExpression(ExpressionGeneratorContext context) + { + var startMethod = typeof(PeriodExpressionGenerator) + .GetMethod(nameof(GetStart))!; + var start = Expression.Call(startMethod, context.FilterMember); + var body = Expression.GreaterThanOrEqual(context.ModelMember, start); + var predicate = Expression.Lambda(body, context.Model); + + var where = ExpressionGenerator.CreateWhereCall( + context.Model.Type, + context.Query, + predicate); + + return Expression.Assign(context.Query, where); + } + + public static DateTime GetStart(Period period) => period switch + { + Period.Last7Days => DateTime.UtcNow.Date.AddDays(-7), + Period.ThisMonth => new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1), + _ => DateTime.UtcNow.Date + }; +} +``` + +O contexto fornece `Query`, `Filter`, `Model`, `ModelMember` e `FilterMember`. O retorno normalmente atribui um novo `Where(...)` a `context.Query`. + +O gerador customizado controla toda a semântica dessa propriedade, inclusive o que fazer com valores vazios. A guarda de `IgnoreIfIsEmpty` não é adicionada automaticamente nesse caminho; se o critério for opcional, trate a ausência dentro da expressão gerada ou escolha outro ponto de extensão. + +### 8.5 Precedência + +Para cada par `(modelo, filtro)`, a resolução ocorre nesta ordem: + +1. specifier já registrado/cacheado com `AddSpecifier`; +2. `ISpecifier` resolvido por DI; +3. método público no filtro com assinatura `IQueryable -> IQueryable`; +4. geração por propriedades, atributos e configurações. + +O resultado é cacheado por processo. Faça configurações globais antes da primeira consulta. + +## 9. Ordenação + +### 9.1 Ordenação dinâmica + +```csharp +criteria.OrderBy(new Sorting +{ + OrderBy = "CreatedAt", + Direction = ListSortDirection.Descending +}); +``` + +Vários critérios são aplicados na ordem informada: + +```csharp +criteria.OrderBy( +[ + new Sorting { OrderBy = "Status" }, + new Sorting { OrderBy = "CreatedAt", Direction = ListSortDirection.Descending } +]); +``` + +O gerador padrão resolve propriedades e caminhos aninhados. Há fallback case-insensitive por segmento para caminhos com `.` ou `-`. + +### 9.2 Nomes registrados + +Registre nomes públicos estáveis, especialmente para navegações, expressões calculadas ou contratos HTTP: + +```csharp +cfg.AddOrderBy("customer", o => o.Customer.Name); +cfg.AddOrderBy("total", o => + o.Items.Sum(i => i.Quantity * i.UnitPrice)); +``` + +Uso: + +```csharp +criteria.OrderBy(new Sorting { OrderBy = "customer" }); +``` + +### 9.3 Parsing + +`Sorting.TryParse` aceita: + +- `Name` +- `Name asc` +- `Name desc` +- `Name-asc` +- `Name-desc` +- JSON no formato `{"orderBy":"Name","direction":1}` + +`Sorting.ToString()` produz `Name` para ascendente e `Name-desc` para descendente. + +### 9.4 Erros e ordenação default + +Uma propriedade inexistente lança `OrderByNotSupportedException`, derivada de `OrderByException`. Uma ordenação que o provider não consegue traduzir também é encapsulada em `OrderByException` durante a materialização. + +Quando há `Skip`, `Take` ou paginação e nenhuma ordenação foi aplicada, SmartSearch adiciona `Id` ascendente. Portanto, modelos paginados precisam de uma propriedade `Id` ordenável ou de uma ordenação explícita/registrada. + +## 10. Paginação, limites e `SearchOptions` + +### 10.1 API fluente + +```csharp +criteria.UsePages(itemsPerPage: 20, pageNumber: 1); +criteria.FetchPage(2); +criteria.Skip(10); +criteria.Take(50); +criteria.SkipTake(skip: 10, take: 50); +criteria.UseCount(); +criteria.UseCount(false); +criteria.UseLastCount(lastCount); +``` + +Quando `Page > 0`, a paginação tem precedência sobre `Skip`/`Take`. + +Sem `UsePages`, `Take`, `Skip` ou `WithOptions`, `AsSearch().ToList()` não impõe limite: ele materializa todos os itens correspondentes e retorna `ItemsPerPage = 0`. Em endpoints, defina limites explicitamente. + +### 10.2 `SearchOptions` + +`SearchOptions` foi projetado para receber parâmetros de query string: + +```csharp +var options = new SearchOptions +{ + Page = 2, + ItemsPerPage = 25, + Count = true +}; + +var result = await criteria + .WithOptions(options) + .FilterBy(filter) + .AsSearch() + .ToListAsync(ct); +``` + +`WithOptions` chama `AvoidEmpty()`. Se `Page`, `ItemsPerPage`, `Skip` e `Take` estiverem todos nulos, usa página 1 com 10 itens. + +Outras operações úteis: + +```csharp +var options = new SearchOptions() + .OrderBy("name") + .OrderByDesc("createdAt"); + +options.UpdateFromResult(previousResult); +options.AllItens(); // nome preservado pela API atual +``` + +`UseCount(false)` evita calcular o total e retorna `Count = 0`/`Pages = 0`. `UseLastCount(n)` reutiliza um total positivo conhecido e evita nova contagem. + +### 10.3 Metadados de `IResultList` + +```csharp +var result = await criteria + .UsePages(20, 1) + .AsSearch() + .ToListAsync(ct); + +var items = result.Items; +var total = result.Count; +var currentPage = result.Page; +var pages = result.Pages; +var skipped = result.Skipped; +var taken = result.Taken; +var sortings = result.Sortings; +``` + +`Pages` usa arredondamento para cima. Por exemplo, 3 itens com 2 por página resultam em 2 páginas. + +`ToAsyncListAsync()` retorna `IAsyncResultList`, cujo `Items` é `IAsyncEnumerable`. A contagem, quando habilitada, é obtida antes de expor o stream. + +`Projections` e `GetProjection()` estão reservados para agregados futuros. Os result lists padrão não preenchem `Projections`, e `ResultList.GetProjection()` lança `NotImplementedException`. Não use essa superfície em código atual. + +## 11. Projeção para DTO + +### 11.1 Selector por convenção + +```csharp +var result = await criteria + .FilterBy(filter) + .Select() + .UsePages(20, 1) + .ToListAsync(ct); +``` + +O gerador de selector tenta mapear propriedades correspondentes e suporta cenários como propriedades aninhadas, nullables, enums, subobjetos e coleções. Se não puder gerar o selector, a execução lança uma exceção de selector não encontrado. + +Para contrato crítico ou mapeamento não trivial, prefira registrar a expressão: + +```csharp +cfg.AddSelector(o => new OrderDto +{ + Id = o.Id, + Number = o.Number, + CustomerName = o.Customer.Name, + Total = o.Items.Sum(i => i.Quantity * i.UnitPrice) +}); +``` + +### 11.2 Selector inline + +```csharp +var order = await criteria + .FilterBy(new OrderByIdFilter { Id = 10 }) + .Select(o => new OrderDto + { + Id = o.Id, + Number = o.Number + }) + .FirstOrDefaultAsync(ct); +``` + +`Select()` e `AsSearch()` desativam tracking nas opções da criteria. A ordenação é aplicada antes da projeção e preservada no resultado. + +## 12. Terminais, tracking e resultados + +| Terminal | Retorno | Tracking EF | Hints | +|---|---|---|---| +| `Collect()` | `IReadOnlyList` | sim | sim | +| `Exists()` | `bool` | não materializa entidade | não | +| `FirstOrDefault()` | `TEntity?` | sim | sim | +| `Single()` | `TEntity` | sim | sim | +| `AsSearch().ToList()` | `IResultList` | não | sim | +| `Select().ToList()` | `IResultList` | não | não | +| `Select().FirstOrDefault()` | `TDto?` | não | não | + +Todos possuem variantes async quando aplicável. + +`Single()` e `SingleAsync()` lançam `InvalidOperationException` se não houver exatamente um item. Use `FirstOrDefault` quando “não encontrado” fizer parte do fluxo esperado. + +`Collect()` respeita filtros, ordenação, `Skip`, `Take` e paginação, mas retorna apenas itens, sem metadados. + +Importante: `AsSearch()` e `Select()` alteram as opções compartilhadas da criteria para no-tracking. Não faça isto: + +```csharp +// Evite ramificar/reutilizar a mesma instância. +var search = criteria.AsSearch(); +var tracked = criteria.Collect(); // também será no-tracking +``` + +Obtenha outra `ICriteria` para uma consulta independente. + +## 13. Operation Hints e carregamento de agregados + +SmartSearch não expõe `Include(...)` em `ICriteria`. Para carregar navegações quando o retorno é entidade, integre com Operation Hint. + +Instale no projeto de infraestrutura: + +```bash +dotnet add package RoyalCode.OperationHint.EntityFramework +``` + +Registre os includes por `(entidade, tipo de hint)`: + +```csharp +public enum OrderHints +{ + WithCustomer, + WithItems +} + +builder.Services.ConfigureOperationHints(registry => +{ + registry.AddIncludesHandler((hint, includes) => + { + if (hint is OrderHints.WithCustomer) + includes.IncludeReference(o => o.Customer); + + if (hint is OrderHints.WithItems) + includes.IncludeCollection(o => o.Items); + }); +}); +``` + +Hints locais pertencem somente à criteria: + +```csharp +var order = await criteria + .UseHints(OrderHints.WithCustomer, OrderHints.WithItems) + .FilterBy(new OrderByIdFilter { Id = 10 }) + .FirstOrDefaultAsync(ct); +``` + +`UseHints` exige ao menos um valor. Array nulo lança `ArgumentNullException`; array vazio lança `ArgumentException`. + +Hints ambientes valem para as consultas no mesmo escopo: + +```csharp +var container = serviceProvider.GetRequiredService(); +container.AddHint(OrderHints.WithCustomer); + +var orders = await criteria.CollectAsync(ct); +``` + +Hints locais e ambientes são combinados. Eles são aplicados somente ao materializar entidades e não afetam `Exists`, contagens ou DTOs. Sem Operation Hint registrado, são ignorados sem erro. + +O mesmo `AddIncludesHandler` também registra o handler usado pelo fluxo pós-carga do Operation Hint. Em um repository que obteve a entidade por `Find`, é possível aplicar os hints ao objeto já carregado: + +```csharp +var container = serviceProvider.GetRequiredService(); +var performer = serviceProvider.GetRequiredService(); + +container.AddHint(OrderHints.WithItems); + +var order = await db.Set().FindAsync([id], ct); +if (order is not null) + performer.Perform(order, db); +``` + +Para DTOs, projete os dados necessários no selector: + +```csharp +var dto = await criteria + .Select(o => new OrderDto + { + Id = o.Id, + CustomerName = o.Customer.Name + }) + .FirstOrDefaultAsync(ct); +``` + +## 14. Helpers para ASP.NET Core + +O pacote `RoyalCode.SmartSearch.AspNetCore` fornece endpoints GET para Minimal API. + +```bash +dotnet add package RoyalCode.SmartSearch.AspNetCore +``` + +### 14.1 Matriz dos helpers + +| Helper | Resultado 200 | Opções | +|---|---|---| +| `MapSearch` | `IResultList` | filtro, sorting, paginação e contagem | +| `MapSearch` | `IResultList` | idem, com projeção | +| `MapList` | `IReadOnlyList` | filtro e sorting, sem metadados | +| `MapList` | `IReadOnlyList` | idem, com projeção | +| `MapFirst` | primeira entidade | filtro e sorting | +| `MapSelectFirst` | primeiro DTO | filtro, sorting e projeção | + +Todos retornam 204 quando não há resultado, 400 (`InvalidParameter`) para sorting inválido e 500 (`InternalError`) para erro inesperado. Os tipos `MatchSearch<>`, `MatchList<>` e `MatchFirst<>` também publicam metadados de endpoint. + +### 14.2 Mapeamento básico + +```csharp +var group = app.MapGroup("/api"); + +group.MapSearch("/orders"); +group.MapList("/products"); +group.MapFirst("/products/first"); +group.MapSelectFirst("/customers/first"); +``` + +O filtro é ligado da query string com `[AsParameters]`. `MapSearch` também recebe `SearchOptions`; `orderby` é ligado separadamente como `Sorting[]?`. + +Exemplos de URL: + +```text +GET /api/orders?customerName=maria&page=1&itemsPerPage=20&orderby=createdAt-desc +GET /api/products?active=true&orderby=price +``` + +### 14.3 Configuração adicional e parâmetros de rota + +O delegate de configuração pode acrescentar filtros, hints e outras opções: + +```csharp +group.MapSearch( + "/customers/{customerId:int}/orders", + (customerId, criteria) => + { + criteria.FilterBy(new OrdersByCustomerFilter + { + CustomerId = customerId + }); + }); +``` + +Há overloads com identificadores de rota adicionais. A ordem dos tipos genéricos é sempre entidade, DTO quando houver, filtro e identificadores. + +Os helpers executam o delegate depois de aplicar opções, sortings e o filtro recebido. Como a criteria é mutável, o delegate pode apenas chamar os métodos fluentes; não precisa retornar a instância. + +### 14.4 Endpoint manual + +Use `ICriteria` diretamente quando o contrato HTTP ou o tratamento de erro precisar ser customizado: + +```csharp +app.MapGet("/orders", async Task> ( + [AsParameters] OrderFilter filter, + [AsParameters] SearchOptions options, + [FromQuery] Sorting[]? orderby, + [FromServices] ICriteria criteria, + CancellationToken ct) => +{ + try + { + var result = await criteria + .WithOptions(options) + .OrderBy(orderby) + .FilterBy(filter) + .Select() + .ToListAsync(ct); + + return result.Count == 0 + ? TypedResults.NoContent() + : TypedResults.Ok(result); + } + catch (OrderByException ex) + { + return Problems.InvalidParameter(ex.Message, "orderby"); + } +}); +``` + +Esse exemplo pressupõe as integrações de SmartProblems usadas pelo pacote ASP.NET Core. + +## 15. Referência rápida da API + +Configuração global: + +```csharp +cfg.Add(); +cfg.Add(typeof(TEntity)); +cfg.AddOrderBy(name, expression); +cfg.AddSelector(expression); +cfg.AddSpecifier(function); +cfg.ConfigureSpecifierGenerator(configure); +``` + +Construção da criteria: + +```csharp +criteria.FilterBy(filter); +criteria.OrderBy(sorting); +criteria.OrderBy(sortings); +criteria.UseHints(hints); +criteria.WithOptions(options); +criteria.UsePages(itemsPerPage, pageNumber); +criteria.FetchPage(pageNumber); +criteria.Skip(skip); +criteria.Take(take); +criteria.SkipTake(skip, take); +criteria.UseCount(useCount); +criteria.UseLastCount(lastCount); +``` + +Conversão e terminais: + +```csharp +criteria.Collect(); +criteria.CollectAsync(ct); +criteria.Exists(); +criteria.ExistsAsync(ct); +criteria.FirstOrDefault(); +criteria.FirstOrDefaultAsync(ct); +criteria.Single(); +criteria.SingleAsync(ct); + +criteria.AsSearch().ToList(); +criteria.AsSearch().ToListAsync(ct); +criteria.AsSearch().ToAsyncListAsync(ct); + +criteria.Select().ToListAsync(ct); +criteria.Select(expression).FirstOrDefaultAsync(ct); +``` + +## 16. Erros comuns + +### 16.1 Passar lambda para `FilterBy` + +```csharp +// ❌ FilterBy espera um objeto filtro. +criteria.FilterBy(o => o.Status == OrderStatus.Paid); + +// ✅ Modele o critério. +criteria.FilterBy(new OrderFilter { Status = OrderStatus.Paid }); +``` + +Para lógica não declarativa, use predicate configurado, specifier, método no filtro ou gerador de expressão. + +### 16.2 Reutilizar a mesma criteria + +```csharp +// ❌ Os filtros se acumulam na mesma instância. +var paid = criteria.FilterBy(new OrderFilter { Status = OrderStatus.Paid }); +var cancelled = criteria.FilterBy(new OrderFilter { Status = OrderStatus.Cancelled }); + +// ✅ Obtenha duas criterias transientes. +var paidCriteria = provider.GetRequiredService>(); +var cancelledCriteria = provider.GetRequiredService>(); +``` + +### 16.3 Usar tipo não-nullable para filtro opcional + +```csharp +// ❌ false/0/default ficam ambíguos com “não informado”. +public bool Active { get; set; } +public int Minimum { get; set; } + +// ✅ null significa “não filtrar”. +public bool? Active { get; set; } +public int? Minimum { get; set; } +``` + +### 16.4 Declarar `In` como array ou lista + +```csharp +// ❌ A emissão atual exige IEnumerable como tipo declarado. +public OrderStatus[]? Statuses { get; set; } + +// ✅ +public IEnumerable? Statuses { get; set; } +``` + +### 16.5 Esperar hints em DTO ou `Exists` + +```csharp +// ❌ UseHints não executa Include depois da projeção. +criteria.UseHints(OrderHints.WithCustomer).Select(); + +// ✅ Projete o campo relacionado no selector. +criteria.Select(o => new OrderDto { CustomerName = o.Customer.Name }); +``` + +### 16.6 Paginar sem ordenação estável + +Sem sorting explícito, consultas limitadas usam `Id` ascendente. Se a entidade não tiver `Id`, a execução falha. Mesmo quando existe, defina uma ordenação pública estável quando a paginação fizer parte do contrato. + +### 16.7 Assumir que `AsSearch()` pagina automaticamente + +```csharp +// ⚠️ Sem opções, não há limite implícito. +var all = await criteria.AsSearch().ToListAsync(ct); + +// ✅ Endpoint paginado explicitamente. +var page = await criteria.UsePages(20, 1).AsSearch().ToListAsync(ct); + +// ✅ SearchOptions vazio aplica o default 10/1 via WithOptions. +var defaultPage = await criteria + .WithOptions(new SearchOptions()) + .AsSearch() + .ToListAsync(ct); +``` + +### 16.8 Usar `GetProjection()` + +Essa API ainda não está implementada. Use somente os metadados atuais de `IResultList`. + +## 17. Boas práticas + +- Crie uma nova `ICriteria` para cada consulta. +- Modele filtros como classes pequenas, com propriedades nullable para critérios opcionais. +- Use atributos apenas quando a convenção não expressar operador, alvo ou composição. +- Prefira selectors registrados para contratos DTO importantes. +- Registre nomes de sorting estáveis em vez de expor detalhes internos da entidade. +- Defina limite explícito em endpoints e ordenação estável em consultas paginadas. +- Use `Collect`/`FirstOrDefault`/`Single` somente quando precisar de entidades com tracking. +- Use `AsSearch` ou `Select` para leitura. +- Use hints para grafos de entidade; use projeção para DTOs. +- Configure defaults, specifiers, factories, sortings e selectors antes da primeira busca. +- Capture `OrderByException` em bordas manuais de API e converta para erro de entrada. +- Propague `CancellationToken` em todos os terminais async. + +Para geração de código por IA, use [`smartsearch.ai-rules.md`](smartsearch.ai-rules.md), que contém regras imperativas, matrizes de decisão, receitas e checklist. diff --git a/RoyalCode.EnterprisePatterns/.docs/validations.ai-rules.md b/RoyalCode.EnterprisePatterns/.docs/validations.ai-rules.md new file mode 100644 index 0000000..fada0e6 --- /dev/null +++ b/RoyalCode.EnterprisePatterns/.docs/validations.ai-rules.md @@ -0,0 +1,434 @@ +# SmartValidations AI Rules + +Use SmartValidations to produce structured `Problems` with `RuleSet` and `IValidable`. + +## Goal + +- Generate synchronous validation code for .NET models, requests, DTOs, value objects and aggregates. +- Return `Problems?` or implement `bool HasProblems(out Problems? problems)`. +- Let `RuleSet` create `Problems.InvalidParameter(...)` and fill metadata. +- Use SmartProblems conversion/response helpers from the target project after validation. + +## Core Rules + +- Use `Rules.Set()` when validating from inside type `T`. +- Use `Rules.Set()` for standalone helper methods. +- Use `RuleSet.For()` only when matching an existing local style that prefers it. +- End object validation with `.HasProblems(out problems)`. +- Return the `RuleSet` directly from helpers returning `Problems?`; implicit conversion is supported. +- Keep `RuleSet` local and synchronous. It is a `readonly ref struct`. +- Do not store `RuleSet` in fields, properties, arrays, closures or long-lived state. +- Do not capture `RuleSet` across `await`, async lambdas, iterators or deferred execution. +- Do not throw exceptions for expected validation failures. +- Do not create `Problem` manually for common validation rules. +- Do not use expression selectors such as `x => x.Property`; `RuleSet` uses `CallerArgumentExpression`. +- Do not pass property names as strings when the validated expression can be passed directly. + +## Decision Matrix + +- Required string: `NotEmpty(value)`. +- Optional string that may be null but not empty when provided: `NullOrNotEmpty(value)`. +- Required collection: `NotEmpty(values)` and then `Nested(values, ...)` or `Validate(values)` when items must be validated. +- Optional collection: `Nested(values, ...)`; add `NotEmpty(values)` only when empty collection is invalid. +- Required nested object: `NotNullNested(value, validator)` or `NotNullNested(value)` when it implements `IValidable`. +- Optional nested object: `Nested(value, validator)` or `Nested(value)` when it implements `IValidable`. +- Required struct value object: `Validate(value)`. +- Struct value object collection: `Validate(values)`. +- Inclusive lower bound: `Min(value, min)`. +- Inclusive upper bound: `Max(value, max)`. +- Inclusive range: `MinMax(value, min, max)`. +- Positive number: `Positive(value)`. +- Non-zero number: `NotZero(value)`. +- Compare two values: `LessThan`, `LessThanOrEqual`, `GreaterThan`, `GreaterThanOrEqual`. +- Value must equal a fixed constant: `Equal(value, expected)`; must differ from it: `NotEqual(value, expected)`. +- Two properties must match (e.g. password confirmation): `BothEqual(value1, value2)`; must differ: `BothNotEqual(value1, value2)`. +- Two fields must be filled together or both null: `BothNullOrNotEmpty(value1, value2)`. +- String format with no built-in rule: `Matches(value, pattern, patternDescription)`. +- Required email: `NotEmpty(email).Email(email)`. +- Optional email: `When(email is not null, s => s.Email(email))`; `Email(null)` reports a problem, so guard optional fields with `When`. +- Absolute URL: `Url(value)` or `AbsoluteUrl(value)`. +- HTTPS URL: `HttpsUrl(value)`. +- Relative URL/path: `RelativeUrl(value)`. +- Future date: `InFuture(value)`. +- Past date: `InPast(value)`. +- Date must be today: `Today(value)`. +- Date after/before fixed values: `After`, `Before`, `Between`. +- Domain-specific condition not covered by built-ins: `Must` or `BothMust` with a stable `ruleName`. + +## Standard DTO Pattern + +```csharp +using RoyalCode.SmartProblems; +using RoyalCode.SmartValidations; + +public sealed class RegisterUserRequest : IValidable +{ + public string Name { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public int Age { get; set; } + + public bool HasProblems(out Problems? problems) + { + return Rules.Set() + .NotEmpty(Name) + .NotEmpty(Email) + .Email(Email) + .Min(Age, 18) + .HasProblems(out problems); + } +} +``` + +Use this shape for request/command/DTO classes when the project accepts validation on the model itself. + +## Standalone Validator Pattern + +```csharp +public static Problems? ValidateProduct(string sku, string? name, decimal price) +{ + return Rules.Set() + .NotEmpty(sku) + .NullOrLength(name, 3, 120) + .Min(price, 0m); +} +``` + +Use this shape for service-level or handler-level validation when the model type should not implement `IValidable`. + +## Required And Optional Values + +```csharp +return Rules.Set() + .NotEmpty(Sku) + .NotEmpty(Name) + .NullOrLength(Description, 0, 500) + .NullOrNotEmpty(ExternalReference) + .HasProblems(out problems); +``` + +- Use `NullOr*` only when `null` is valid. +- Use `NotEmpty` when `null`, empty, zero, default date, empty GUID or empty collection is invalid. +- Use `NotNull` when default value is acceptable but `null` is not. + +## Numeric And Range Rules + +```csharp +return Rules.Set() + .Positive(Quantity) + .Min(UnitPrice, 0m) + .Max(DiscountPercent, 100m) + .MinMax(Score, 0, 100) + .NotZero(ExternalId) + .HasProblems(out problems); +``` + +- Prefer `Min`, `Max`, `MinMax`, `Positive`, `Negative`, `Zero` and `NotZero` for fixed constraints. +- Use comparison rules only when both operands are meaningful values from the model. +- Nullable comparison overloads treat `null` as the smallest possible value (same convention as `Comparer.Default`); add `NotNull` before them only when `null` itself must be invalid. + +```csharp +return Rules.Set() + .LessThan(Start, End) + .GreaterThanOrEqual(End, Start) + .HasProblems(out problems); +``` + +## String Rules + +```csharp +return Rules.Set() + .Length(Currency, 3, 3) + .MinLength(Password, 8) + .MaxLength(DisplayName, 80) + .OnlyLettersOrDigits(UserName) + .NoWhiteSpace(UserName) + .StartsWith(Code, "USR-", StringComparison.Ordinal) + .HasProblems(out problems); +``` + +- Use `Length(value, n, n)` for exact length. +- Pass `StringComparison` explicitly for `StartsWith`, `EndsWith`, `Contains`, `NotContain`, `Equal` and `NotEqual` when case or culture matters. +- Use `Matches`/`NotMatches` for regex only when no built-in string rule exists. + +## Email, URL And Date Rules + +```csharp +return Rules.Set() + .NotEmpty(Email) + .Email(Email) + .HttpsUrl(WebhookUrl) + .RelativeUrl(ReturnPath) + .InFuture(ExpiresAt) + .HasProblems(out problems); +``` + +```csharp +return Rules.Set() + .After(EndAt, StartAt) + .Between(StartAt, windowStart, windowEnd) + .HasProblems(out problems); +``` + +- Use `Email` after `NotEmpty` for required email fields. +- For optional email or URL fields, wrap the format rule in `When(value is not null, s => s.Email(value))`; format rules report a problem for null values. +- Use `Url` for generic absolute URL accepted by `UrlAttribute`. +- Use `AbsoluteUrl` when an absolute URL is semantically required. +- Use `HttpsUrl` when HTTPS is required. +- Use `RelativeUrl` for paths such as `/orders/1`, `orders/1`, `../orders/1` or query-only relative targets. +- Use date rules directly in `RuleSet`; do not wrap `BuildInPredicates` in `Must` for these cases. +- `InPast`, `InFuture` and `Today` read the current time from `BuildInPredicates.Clock`, a `TimeProvider` that defaults to `TimeProvider.System`. +- In tests, assign a fixed `TimeProvider` to `BuildInPredicates.Clock` for deterministic date validations, and restore the original value afterwards. +- Do not use `DateTime.Now` workarounds to make date rules testable; replace `BuildInPredicates.Clock` instead. + +## Nested Objects + +For required nested object: + +```csharp +public bool HasProblems(out Problems? problems) +{ + return Rules.Set() + .NotEmpty(CustomerId) + .NotNullNested(ShippingAddress, address => Rules.Set
() + .WithPropertyPrefix(nameof(address)) + .NotEmpty(address.Street) + .NotEmpty(address.City) + .NotEmpty(address.ZipCode)) + .HasProblems(out problems); +} +``` + +For optional nested object: + +```csharp +public bool HasProblems(out Problems? problems) +{ + return Rules.Set() + .Nested(BillingAddress, address => Rules.Set
() + .WithPropertyPrefix(nameof(address)) + .NotEmpty(address.Street) + .NotEmpty(address.City) + .NotEmpty(address.ZipCode)) + .HasProblems(out problems); +} +``` + +Rules: + +- `Nested(null, ...)` does not add a problem. +- `NotNullNested(null, ...)` adds a problem for the nested property. +- Always use `WithPropertyPrefix(nameof(parameter))` inside reusable nested validators when validating parameter expressions such as `address.Street`. +- Use an outer `.WithPropertyPrefix(nameof(rootVariable))` when the root expression includes a local variable name that should not appear in the final path. + +Reusable validator: + +```csharp +static Problems? ValidateAddress(Address address) + => Rules.Set
() + .WithPropertyPrefix(nameof(address)) + .NotEmpty(address.Street) + .NotEmpty(address.City) + .NotEmpty(address.ZipCode); + +var set = Rules.Set() + .WithPropertyPrefix(nameof(order)) + .NotNullNested(order.ShippingAddress, address => ValidateAddress(address)); +``` + +Expected path for invalid street: `ShippingAddress.Street`. + +## Nested Collections + +For required collection with required valid items: + +```csharp +public bool HasProblems(out Problems? problems) +{ + return Rules.Set() + .NotEmpty(Items) + .When(Items is not null, s => s.NotNullNested(Items, item => + Rules.Set() + .WithPropertyPrefix(nameof(item)) + .NotEmpty(item.ProductId) + .Positive(item.Quantity) + .Min(item.Price, 0m))) + .HasProblems(out problems); +} +``` + +For optional collection: + +```csharp +return Rules.Set() + .Nested(OptionalItems, item => Rules.Set() + .WithPropertyPrefix(nameof(item)) + .NotEmpty(item.ProductId) + .Positive(item.Quantity)) + .HasProblems(out problems); +``` + +Rules: + +- Collection item paths include indexes, for example `Items[0].ProductId`. +- Use `NotEmpty(values)` when an empty collection is invalid. +- Use `NotNullNested(values, ...)` when a `null` collection is invalid; it also reports `null` items with indexed paths such as `Items[2]`. +- Use `Nested(values, ...)` when a `null` collection is valid; `null` items are skipped. +- For required non-empty collections with non-null items, use `NotEmpty(values)` plus `When(values is not null, s => s.NotNullNested(values, ...))` to avoid duplicate null problems. + +## IValidable Classes + +When a nested class implements `IValidable`, call the overload without a validator: + +```csharp +public sealed class Customer : IValidable +{ + public Address? Address { get; set; } + + public bool HasProblems(out Problems? problems) + { + return Rules.Set() + .NotNullNested(Address) + .HasProblems(out problems); + } +} +``` + +- Use `Nested(Address)` if `Address` is optional. +- Use `NotNullNested(Address)` if `Address` is required. + +## Struct Value Objects + +Implement `IValidable` on structs and validate with `Validate`. + +```csharp +public readonly struct Money : IValidable +{ + public decimal Amount { get; } + public string Currency { get; } + + public bool HasProblems(out Problems? problems) + { + return Rules.Set() + .Min(Amount, 0m) + .Length(Currency, 3, 3) + .HasProblems(out problems); + } +} + +return Rules.Set() + .Validate(Total) + .Validate(Prices) + .HasProblems(out problems); +``` + +- `Validate(value)` validates one struct. +- `Validate(values)` validates a collection and indexes paths. + +## Conditional Rules + +Use `When` for conditional requirements: + +```csharp +return Rules.Set() + .When(IsGuest, s => s + .NotEmpty(Email) + .Email(Email)) + .HasProblems(out problems); +``` + +Use `Unless` for rules that apply when a condition is false: + +```csharp +return Rules.Set() + .Unless(HasAddressOnFile, s => s + .NotNullNested(ShippingAddress, address => ValidateAddress(address))) + .HasProblems(out problems); +``` + +Use alternative groups when one of two validation groups must pass: + +```csharp +var set = Rules.Set() + .Unless( + s => s.NotEmpty(PromoCode), + s => s.Min(TotalAmount, 100m)); +``` + +## Custom Rules + +Use `Must` only when there is no built-in rule: + +```csharp +return Rules.Set() + .Must(Password, + p => p is { Length: >= 8 } && p.Any(char.IsDigit) && p.Any(char.IsUpper), + (property, _) => $"{property} must contain at least 8 chars, an uppercase letter and a digit.", + ruleName: "password.policy") + .HasProblems(out problems); +``` + +Use `BothMust` for custom validation involving two values: + +```csharp +return Rules.Set() + .BothMust(Start, End, + (start, end) => start < end, + (startProperty, endProperty, _, _) => $"{startProperty} must be before {endProperty}.", + ruleName: "period.order") + .HasProblems(out problems); +``` + +Rules: + +- Always provide a stable `ruleName`. +- Use the display-name parameter from `messageFormatter`. +- Prefer built-in methods over `Must`. +- Do not use `Must` for URL, email, numeric sign, length, range or date rules already covered by `RuleSet`. + +## Metadata + +- Rely on `RuleSet` to set metadata. +- `Rules.RuleProperty` stores `rule`. +- `Rules.CurrentValueProperty` stores `current`. +- `Rules.ExpectedValueProperty` stores `expected`. +- `Rules.PatternProperty` stores `pattern`. +- Two-operand rules attach `properties` and `values`. +- Do not manually set these metadata fields for built-in rules. + +## Output And API Integration + +Use this shape in handlers: + +```csharp +if (request.HasProblems(out var problems)) +{ + return problems; +} +``` + +or: + +```csharp +var problems = ValidateProduct(sku, name, price); +if (problems is not null) +{ + return problems; +} +``` + +- Convert `Problems` to the response format used by the project. +- In ASP.NET APIs using SmartProblems, use the project’s existing SmartProblems integration for `ProblemDetails`. +- Do not serialize validation failures by hand when SmartProblems helpers are available. + +## Anti-Patterns + +- Do not write `Problems.InvalidParameter(...)` manually for common input validation. +- Do not pass `"Name"` or `"Address.Street"` when `.NotEmpty(Name)` or `.NotEmpty(address.Street)` can capture the expression. +- Do not use `GreaterThan(value, 0)` for positive numbers; use `Positive(value)`. +- Do not use `GreaterThanOrEqual(value, 0)` for lower bounds; use `Min(value, 0)`. +- Do not use `BuildInPredicates.IsHttpsUrl` inside `Must`; use `HttpsUrl(value)`. +- Do not use `BuildInPredicates.InFuture` inside `Must`; use `InFuture(value)`. +- Do not call async APIs inside `RuleSet` builders. +- Do not keep `RuleSet` in a variable that outlives the validation method. +- Do not validate required nested objects with `Nested`; use `NotNullNested`. +- Do not validate optional nested objects with `NotNullNested`; use `Nested`. diff --git a/RoyalCode.EnterprisePatterns/.docs/validations.md b/RoyalCode.EnterprisePatterns/.docs/validations.md index 7416d48..f2aa64b 100644 --- a/RoyalCode.EnterprisePatterns/.docs/validations.md +++ b/RoyalCode.EnterprisePatterns/.docs/validations.md @@ -1,7 +1,7 @@ # Documentação da API SmartValidations (RuleSet, IValidable) Esta documentação apresenta os conceitos, funcionalidades e exemplos práticos para usar a biblioteca SmartValidations em projetos .NET. -Também serve de referência para ferramentas de IA (ex.: GitHub Copilot) gerarem código correto com base na API da biblioteca. +Para instruções objetivas de uso por ferramentas de IA, consulte também `.docs/validations.ai-rules.md`. Projetos alvo: .NET 8, .NET 9 e .NET 10. @@ -15,7 +15,7 @@ Sumário 7. Referência da API 8. Boas práticas 9. Resumo -10. Instruções para Ferramentas de IA (GitHub Copilot) +10. Documentação para IA ## 1. Introdução @@ -35,11 +35,12 @@ Benefícios principais: - `rule` (nome da regra), `current` (valor atual), `expected` (valor(es) esperado(s)), `pattern` (em regras de regex), `properties` e `values` (em regras com 2 operandos). - Integra com display names e prefixos de propriedades, removendo prefixos configurados ao encadear problemas. - Conversão implícita para `Problems?` e método `HasProblems(out Problems?)`. + - É um `readonly ref struct`: use localmente em métodos síncronos de validação, especialmente em `HasProblems(out Problems?)`. - `IValidable` - Contrato simples com `HasProblems(out Problems?)` para permitir validação de objetos e value objects (structs). -- Predicados internos (`BuildInPredicates`) +- Predicados públicos de apoio (`BuildInPredicates`) - Conjunto abrangente de verificações: vazios, igualdade, comparações, faixas, tamanhos, padrões de string, e utilitários de data/tempo. - Utilizados por `RuleSet` para implementar as regras fluentes. @@ -53,7 +54,7 @@ Benefícios principais: - Utilidades - `WithPropertyPrefix(...)` para normalizar caminhos removendo prefixos conhecidos. - - Regras de e-mail e URL baseadas em `EmailAddressAttribute` e `UrlAttribute`. + - Regras de e-mail, URL, URL HTTPS, URL absoluta e URL relativa. ## 3. Exemplos de uso base @@ -86,7 +87,7 @@ public static Problems? ValidateProduct(string sku, string? name, decimal price) return Rules.Set() .NotEmpty(sku) .NullOrLength(name, 3, 120) - .GreaterThanOrEqual(price, 0) + .Min(price, 0m) ; // conversão implícita para Problems? } ``` @@ -120,23 +121,26 @@ public readonly struct Money : IValidable public bool HasProblems(out Problems? problems) { return Rules.Set() - .GreaterThanOrEqual(Amount, 0) + .Min(Amount, 0m) .Length(Currency, 3, 3) .HasProblems(out problems); } } var prices = new[] { new Money(-1, ""), new Money(10, "USD") }; -var set = Rules.Set().Validate((IEnumerable)prices); +var set = Rules.Set().Validate(prices); if (set.HasProblems(out var problems)) { - // problems conterá caminhos com índices: [0], [1] + // apenas o item inválido gera problemas; o Property de cada problema + // será o nome do argumento com índice: "prices[0]" } ``` +Observação: `Validate` substitui o `Property` dos problemas internos pelo nome do argumento (com índice em coleções). No exemplo, tanto a falha de `Amount` quanto a de `Currency` do primeiro item terão `Property == "prices[0]"`; o campo específico permanece no texto da mensagem. Esse comportamento é pensado para value objects, onde o nome externo é mais significativo que o campo interno — para preservar o caminho completo (ex.: `Items[0].Quantity`), use `Nested` com classes. + ## 5. Exemplos de uso aninhados (objetos e coleções) -Validando objetos opcionais com `NotNullNested` e objetos sempre presentes com `Nested`: +Validando objetos obrigatórios com `NotNullNested` e objetos opcionais com `Nested`: ```csharp public sealed class Address @@ -157,10 +161,12 @@ public sealed class CheckoutRequest : IValidable return Rules.Set() .NotEmpty(CustomerId) .NotNullNested(Shipping, addr => Rules.Set
() + .WithPropertyPrefix(nameof(addr)) .NotEmpty(addr.Street) .NotEmpty(addr.City) .NotEmpty(addr.ZipCode)) .Nested(PastAddresses, addr => Rules.Set
() + .WithPropertyPrefix(nameof(addr)) .NotEmpty(addr.Street) .NotEmpty(addr.City) .NotEmpty(addr.ZipCode)) @@ -172,20 +178,23 @@ public sealed class CheckoutRequest : IValidable Usando `WithPropertyPrefix` para normalizar nomes ao compor validadores reutilizáveis: ```csharp -Problems? ValidateAddress(Address a) +Problems? ValidateAddress(Address address) => Rules.Set
() - .WithPropertyPrefix(nameof(a)) - .NotEmpty(a.Street) - .NotEmpty(a.City) - .NotEmpty(a.ZipCode); + .WithPropertyPrefix(nameof(address)) + .NotEmpty(address.Street) + .NotEmpty(address.City) + .NotEmpty(address.ZipCode); + +var order = new Order { ShippingAddress = new Address() }; -var set2 = Rules.Set() - .Nested(order.ShippingAddress, a => ValidateAddress(a)); +var set2 = Rules.Set() + .WithPropertyPrefix(nameof(order)) + .Nested(order.ShippingAddress, address => ValidateAddress(address)); ``` No exemplo acima, se `Street` estiver vazio, o `Property` do `Problem` será `ShippingAddress.Street`. -Caso não usasse `WithPropertyPrefix`, o `Property` seria `ShippingAddress.a.Street`, incluindo o nome do parâmetro como nome da propriedade. +Sem o `WithPropertyPrefix` externo, o caminho manteria o nome da variável (`order.ShippingAddress.Street`). Sem o `WithPropertyPrefix` interno, o caminho incluiria o nome do parâmetro do validador (`ShippingAddress.address.Street`). ## 6. Exemplos de uso avançados @@ -202,7 +211,19 @@ var set = Rules.Set() // Grupos alternativos: adiciona problemas de ambos se ambos falharem set = set.Unless( s => s.NotEmpty(promoCode), // condição - s => s.Min(totalAmount, 100)); // alternativo + s => s.Min(totalAmount, 100m)); // alternativo +``` + +- URLs especializadas, sinais numéricos e datas: + +```csharp +var set = Rules.Set() + .HttpsUrl(callbackUrl) + .RelativeUrl(returnPath) + .Positive(quantity) + .Min(price, 0m) + .InFuture(expiresAt) + .After(periodEnd, periodStart); ``` - Regras personalizadas (`Must`/`BothMust`) com metadados de regra: @@ -221,6 +242,7 @@ var strong = Rules.Set() - Internacionalização - As mensagens são formatadas por templates (ex.: `R.MinMessageTemplate`) e nomes de exibição via `DisplayNames`. + - Os templates são recursos localizáveis (`R.resx`); a biblioteca inclui inglês (padrão) e `pt-BR`, selecionados pela `CultureInfo.CurrentUICulture` da thread. - Configure seus display names (DataAnnotations ou provedor customizado) para mensagens amigáveis. ## 7. Referência da API @@ -228,7 +250,13 @@ var strong = Rules.Set() Tipos principais: - `RuleSet` (fluent API de validação) - `IValidable` (contrato para validação) -- `BuildInPredicates` (predicados usados pelas regras) +- `BuildInPredicates` (predicados públicos de apoio usados pelas regras) + +Escopo de uso do `RuleSet` +- `RuleSet` é `readonly ref struct`. +- Use em escopo local e síncrono; não armazene em campos, não capture em lambdas assíncronas e não tente atravessar `await`. +- O uso principal esperado é dentro de `HasProblems(out Problems?)` ou funções síncronas que retornam `Problems?`. +- Use como uma única cadeia fluente: após a primeira falha, as cópias de um `RuleSet` compartilham a mesma coleção de `Problems` — não ramifique um `RuleSet` intermediário em cadeias independentes. Criação e inspeção - `Rules.Set()` / `Rules.Set()` / `RuleSet.For()` @@ -241,6 +269,7 @@ Nulos e vazios - `NotEmpty` para: `string`, `INumber`, `T? where T: struct, INumber`, arrays, `ICollection`, `IReadOnlyCollection`, `IEnumerable`, `DateTime(Offset)`, `DateOnly`, `Guid` - `NullOrNotEmpty` para: `string`, `INumber`, `T? where T: struct, INumber` - Duais: `BothNullOrNotEmpty(string?, string?)` +- Semântica de "vazio": zero para números, `MinValue` para datas, `Guid.Empty` para GUIDs, nula/em branco para strings, sem itens para coleções. Igualdade/Desigualdade - `Equal` e `NotEqual` para `string` (com `StringComparison`) e tipos `IEquatable` (inclui versões `Nullable`) @@ -250,20 +279,25 @@ Strings e padrões - `Matches` / `NotMatches` com `string pattern` ou `Regex` - `StartsWith` / `EndsWith` / `Contains` / `NotContain` - `OnlyLetters` / `OnlyDigits` / `OnlyLettersOrDigits` / `NoWhiteSpace` +- As sobrecargas com `string pattern` aplicam `BuildInPredicates.RegexMatchTimeout` (1s) como proteção contra backtracking catastrófico. Numéricos e faixas - `Min` / `Max` / `MinMax` (e variantes `NullOrMin`, `NullOrMax`, `NullOrMinMax`) +- `Positive` / `Negative` / `Zero` / `NotZero` - Tamanho de string: `MinLength` / `MaxLength` / `Length` (e `NullOrMinLength`, `NullOrMaxLength`, `NullOrLength`) Comparações relativas - `LessThan` / `LessThanOrEqual` / `GreaterThan` / `GreaterThanOrEqual` (para tipos `IComparable` e suas variantes `Nullable`) +- Nas variantes `Nullable`, `null` é tratado como o menor valor possível (mesma convenção de `Comparer.Default`). -Datas e horários (via `BuildInPredicates`) +Datas e horários - `InPast` / `InFuture` / `Today` para `DateTime`, `DateTimeOffset`, `DateOnly` - `After` / `Before` / `Between` para os mesmos tipos +- As regras relativas (`InPast`, `InFuture`, `Today`) usam `BuildInPredicates.Clock` (`TimeProvider`, padrão `TimeProvider.System`); substitua em testes para resultados determinísticos. E-mail e URL - `Email(string?)` e `Url(string?)` +- `HttpsUrl(string?)`, `AbsoluteUrl(string?)` e `RelativeUrl(string?)` Customização - `Must(value, predicate, messageFormatter[, ruleName])` @@ -271,9 +305,9 @@ Customização - `BothMust(...)` e `BothMust(...)` Validação aninhada -- Objetos: `Nested(value, Problems? validator)` / `Nested(value, Func)` / `Nested(value) where T: IValidable` -- Coleções: `Nested(IEnumerable, ...)` com indexação automática -- Garantindo não-nulo: `NotNullNested(...)` (mesmas variações) +- Objetos opcionais: `Nested(value, Func)` / `Nested(value, Func)` / `Nested(value) where T: IValidable` +- Coleções opcionais: `Nested(IEnumerable, ...)` com indexação automática; itens `null` são ignorados +- Objetos e coleções obrigatórios: `NotNullNested(...)` com as mesmas variações; gera problema quando o valor/coleção é `null` e, em coleções, quando um item é `null` (com propriedade indexada, ex.: `Items[2]`). Structs com `IValidable` - `Validate(value) where T: struct, IValidable` @@ -295,66 +329,17 @@ Metadados em `Problem` (SmartProblems) - Use `WithPropertyPrefix` para normalizar caminhos ao reutilizar validadores. - Padronize mensagens com templates localizáveis e display names consistentes. - Garanta cobertura com regras `NullOr*` quando campos forem opcionais. +- Para objetos aninhados opcionais, use `Nested`; para obrigatórios, use `NotNullNested`. +- Para limites fixos, prefira `Min`, `Max`, `MinMax`, `Positive`, `Negative`, `Zero` e `NotZero`; deixe `LessThan`/`GreaterThan` para comparação entre valores. +- Use `HttpsUrl`, `AbsoluteUrl`, `RelativeUrl` e regras de data diretamente no `RuleSet` antes de recorrer a `Must`. +- Não armazene `RuleSet` fora do escopo local de validação; ele é um `ref struct`. +- Trate o `RuleSet` como uma cadeia fluente única; não ramifique um set intermediário em cadeias independentes (as cópias compartilham os `Problems`). - Em coleções, valide cada item com `Nested`/`Validate` para obter caminhos com índice. ## 9. Resumo SmartValidations fornece uma maneira fluente, fortemente tipada e performática de validar modelos .NET. Ao invés de lançar exceções, as falhas são representadas por `Problems` ricos em contexto, integráveis com `ProblemDetails` em APIs. Suas APIs cobrem desde regras básicas de vazio/igualdade até validações aninhadas, condicionais e customizadas, com metadados para rastreabilidade e mensagens prontas para localização. -## 10. Instruções para Ferramentas de IA (GitHub Copilot) - -Objetivo: gerar validações seguindo o contrato da API (`RuleSet`, `IValidable`) e produzir `Problems` com metadados corretos. - -Princípios obrigatórios -- Sempre valide com `Rules.Set()` (quando dentro do tipo) ou `Rules.Set()` (fora), e finalize com `HasProblems(out Problems?)` ou conversão implícita para `Problems?`. -- Use os métodos de regra do `RuleSet` (ex.: `NotEmpty`, `Min`, `Equal`, `Matches`, `Nested`, `Validate`) em vez de criar `Problem` manualmente. -- Preserve o `Property` via `CallerArgumentExpression`: passe o próprio argumento observado à regra, não strings de nome manual. -- Em objetos opcionais, primeiro `NotNullNested(...)`; em objetos obrigatórios, use `Nested(...)` diretamente. -- Em coleções, use `Nested(IEnumerable, ...)` ou `Validate(IEnumerable)` para indexar automaticamente (`Items[i].Prop`). - -Padrões de implementação -- DTO/entrada - - Estrutura: - - Método `bool HasProblems(out Problems? problems)`. - - `return Rules.Set() ... .HasProblems(out problems);` - - Campos opcionais: `NullOr*` (ex.: `NullOrLength`) ou `NotNullNested` antes de regras internas. - - Campos obrigatórios: `NotEmpty`/`Min`/`Max`/`Length` conforme tipo. - -- Objetos aninhados - - `Nested(child, c => Rules.Set()...)` para validar filhos obrigatórios. - - `NotNullNested(child, c => Rules.Set()...)` para filhos opcionais. - - Para reuso, normalize com `WithPropertyPrefix(prefix)` no validador reutilizável. - -- Value objects (struct) - - Implementar `IValidable` com `HasProblems(out Problems?)` interno ao struct. - - Em agregados: `Rules.Set().Validate(collectionOfStructs)` para coletar e indexar problemas. - -- Condicionais - - `When(cond, builder)` para aplicar regras sob condição. - - `Unless(cond, builder)` para aplicar regras quando a condição não vale. - - Alternativas: `Unless(conditionRules, alternativeRules)` (se ambos falham, agregue ambos). - -- Regras customizadas - - `Must(value, predicate, messageFormatter[, ruleName])` e `BothMust(...)` quando não houver regra pronta. - - O `messageFormatter` deve usar o display name (`prop`) e indicar claramente o requisito violado. - -- URLs/e-mails e datas - - Use `Email(value)` e `Url(value)` para formatos; prefira `IsHttpsUrl`/`IsAbsoluteUrl` (via predicados) dentro de `Must` quando necessário. - - Datas: utilize comparações (`LessThan/GreaterThanOrEqual`) e predicados de tempo (`InPast/InFuture`) conforme o caso. - -Formato esperado de saída -- Funções devem retornar `Problems?` ou `bool HasProblems(out Problems?)`. -- Não lançar exceções para fluxo de validação. -- Problemas conterão `rule/current/expected/pattern/properties/values` automaticamente via `RuleSet`. - -Exemplos de prompts corretos -- "Gere `HasProblems(out Problems?)` para `RegisterUser` com `NotEmpty(Name)`, `Min(Age,18)`, e `NotNullNested(Address, addr => ... )`." -- "Valide `Order.Items` com `Nested(items, item => Rules.Set().NotEmpty(item.ProductId).GreaterThan(item.Quantity,0))`." -- "Crie regra customizada com `Must(Password, predicate, formatter, ruleName:"password.policy")` e alternativa com `Unless(s => s.NotEmpty(PromoCode), s => s.Min(Total,100))`." -- "Implemente `IValidable` em `struct Price` e valide uma lista com `Rules.Set().Validate(prices)` retornando `Problems?`." - -Antipadrões (evitar) -- Construir `Problem` manualmente para validação de entrada (use `RuleSet`). -- Passar nomes de propriedade como string (quebra refactors e `CallerArgumentExpression`). -- Usar exceções para fluxo esperado de validação. -- Usar Expression para a propriedade (x => x.Prop) (não funciona). \ No newline at end of file +## 10. Documentação para IA + +Use `.docs/validations.ai-rules.md` como documento de instruções para ferramentas de IA em outros projetos e repositórios. diff --git a/RoyalCode.EnterprisePatterns/Directory.Build.props b/RoyalCode.EnterprisePatterns/Directory.Build.props index a8f07d5..aabb908 100644 --- a/RoyalCode.EnterprisePatterns/Directory.Build.props +++ b/RoyalCode.EnterprisePatterns/Directory.Build.props @@ -9,7 +9,7 @@ 0.8.2 - 0.8.13 + 0.9.0 0.1.0 @@ -40,9 +40,9 @@ 10.0.0 - 1.0.0-preview-4.0 - 1.0.0-preview-6.0 + 1.0.0-preview-7.0 + 1.0.0-preview-7.0 1.0.0 - 0.10.5 + 0.11.0 diff --git a/RoyalCode.EnterprisePatterns/EnterprisePatterns.sln b/RoyalCode.EnterprisePatterns/EnterprisePatterns.sln index b19bb77..1d91efd 100644 --- a/RoyalCode.EnterprisePatterns/EnterprisePatterns.sln +++ b/RoyalCode.EnterprisePatterns/EnterprisePatterns.sln @@ -44,22 +44,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RoyalCode.EntityFramework.S EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Libraries", "Libraries", "{8198A8B4-6CCB-4347-97C0-23AFFB879CA1}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RoyalCode.Commands.Abstractions", "RoyalCode.Commands.Abstractions\RoyalCode.Commands.Abstractions.csproj", "{E3A5F862-236E-4F18-AF22-847F297DE7A3}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RoyalCode.Commands.Tests", "RoyalCode.Commands.Tests\RoyalCode.Commands.Tests.csproj", "{95550B5A-64B5-4D19-AB69-D5004B091357}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RoyalCode.Commands.Handlers", "RoyalCode.Commands.Handlers\RoyalCode.Commands.Handlers.csproj", "{158C26A7-315B-44ED-BC47-4F43A85FE5FB}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RoyalCode.Commands.Automation", "RoyalCode.Commands.Automation\RoyalCode.Commands.Automation.csproj", "{E1FED657-EB01-460E-9AA8-B03761F0F884}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RoyalCode.Commands.AspNetCore", "RoyalCode.Commands.AspNetCore\RoyalCode.Commands.AspNetCore.csproj", "{671A631E-884F-4F3F-8435-C6B3A7782F18}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RoyalCode.OperationHint.Tests", "RoyalCode.OperationHint.Tests\RoyalCode.OperationHint.Tests.csproj", "{0960732A-D8DD-4C00-BFDB-23DF767028A9}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "UnitOfWork", "UnitOfWork", "{DD6336C0-C66B-44CD-9BEA-D9ED66EEC7BF}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Commands", "Commands", "{DE4106C5-D631-4B42-AB27-0103F5705693}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Events", "Events", "{80CCF203-3C6E-4B07-B011-04F7800EA52E}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RoyalCode.Events.Abstractions", "RoyalCode.Events.Abstractions\RoyalCode.Events.Abstractions.csproj", "{11408DF7-AF5A-492A-B0B5-93A52B79BBF3}" @@ -89,7 +79,6 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{36D7493B-A101-41D3-9779-9FE82422D617}" ProjectSection(SolutionItems) = preProject .docs\domain.md = .docs\domain.md - .docs\instructions-for-copilot.md = .docs\instructions-for-copilot.md .docs\problems.md = .docs\problems.md .docs\search.md = .docs\search.md .docs\selector.md = .docs\selector.md @@ -147,26 +136,10 @@ Global {A851D07A-3480-4505-AEB4-83A5B61092E4}.Debug|Any CPU.Build.0 = Debug|Any CPU {A851D07A-3480-4505-AEB4-83A5B61092E4}.Release|Any CPU.ActiveCfg = Release|Any CPU {A851D07A-3480-4505-AEB4-83A5B61092E4}.Release|Any CPU.Build.0 = Release|Any CPU - {E3A5F862-236E-4F18-AF22-847F297DE7A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E3A5F862-236E-4F18-AF22-847F297DE7A3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E3A5F862-236E-4F18-AF22-847F297DE7A3}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E3A5F862-236E-4F18-AF22-847F297DE7A3}.Release|Any CPU.Build.0 = Release|Any CPU {95550B5A-64B5-4D19-AB69-D5004B091357}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {95550B5A-64B5-4D19-AB69-D5004B091357}.Debug|Any CPU.Build.0 = Debug|Any CPU {95550B5A-64B5-4D19-AB69-D5004B091357}.Release|Any CPU.ActiveCfg = Release|Any CPU {95550B5A-64B5-4D19-AB69-D5004B091357}.Release|Any CPU.Build.0 = Release|Any CPU - {158C26A7-315B-44ED-BC47-4F43A85FE5FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {158C26A7-315B-44ED-BC47-4F43A85FE5FB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {158C26A7-315B-44ED-BC47-4F43A85FE5FB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {158C26A7-315B-44ED-BC47-4F43A85FE5FB}.Release|Any CPU.Build.0 = Release|Any CPU - {E1FED657-EB01-460E-9AA8-B03761F0F884}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E1FED657-EB01-460E-9AA8-B03761F0F884}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E1FED657-EB01-460E-9AA8-B03761F0F884}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E1FED657-EB01-460E-9AA8-B03761F0F884}.Release|Any CPU.Build.0 = Release|Any CPU - {671A631E-884F-4F3F-8435-C6B3A7782F18}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {671A631E-884F-4F3F-8435-C6B3A7782F18}.Debug|Any CPU.Build.0 = Debug|Any CPU - {671A631E-884F-4F3F-8435-C6B3A7782F18}.Release|Any CPU.ActiveCfg = Release|Any CPU - {671A631E-884F-4F3F-8435-C6B3A7782F18}.Release|Any CPU.Build.0 = Release|Any CPU {0960732A-D8DD-4C00-BFDB-23DF767028A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0960732A-D8DD-4C00-BFDB-23DF767028A9}.Debug|Any CPU.Build.0 = Debug|Any CPU {0960732A-D8DD-4C00-BFDB-23DF767028A9}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -226,14 +199,9 @@ Global {95E28BC1-E43B-4BB0-8136-B27119509A24} = {DF5DDC2D-A42A-402C-8633-06D9BA0C416B} {A851D07A-3480-4505-AEB4-83A5B61092E4} = {8198A8B4-6CCB-4347-97C0-23AFFB879CA1} {8198A8B4-6CCB-4347-97C0-23AFFB879CA1} = {A54A32AB-DA08-4825-A246-DE5A8C69D588} - {E3A5F862-236E-4F18-AF22-847F297DE7A3} = {DE4106C5-D631-4B42-AB27-0103F5705693} {95550B5A-64B5-4D19-AB69-D5004B091357} = {D91CABB8-AE3F-4FE8-B8E3-DEDBD6C2EDB6} - {158C26A7-315B-44ED-BC47-4F43A85FE5FB} = {DE4106C5-D631-4B42-AB27-0103F5705693} - {E1FED657-EB01-460E-9AA8-B03761F0F884} = {DE4106C5-D631-4B42-AB27-0103F5705693} - {671A631E-884F-4F3F-8435-C6B3A7782F18} = {DE4106C5-D631-4B42-AB27-0103F5705693} {0960732A-D8DD-4C00-BFDB-23DF767028A9} = {D91CABB8-AE3F-4FE8-B8E3-DEDBD6C2EDB6} {DD6336C0-C66B-44CD-9BEA-D9ED66EEC7BF} = {4847AB47-2B45-4A3A-BF3B-7F010F21ED86} - {DE4106C5-D631-4B42-AB27-0103F5705693} = {4847AB47-2B45-4A3A-BF3B-7F010F21ED86} {80CCF203-3C6E-4B07-B011-04F7800EA52E} = {4847AB47-2B45-4A3A-BF3B-7F010F21ED86} {11408DF7-AF5A-492A-B0B5-93A52B79BBF3} = {80CCF203-3C6E-4B07-B011-04F7800EA52E} {889B63DF-8646-42FE-8146-15D72757E06A} = {80CCF203-3C6E-4B07-B011-04F7800EA52E} diff --git a/RoyalCode.EnterprisePatterns/RoyalCode.Commands.Automation/RoyalCode.Commands.Automation.csproj b/RoyalCode.EnterprisePatterns/RoyalCode.Commands.Automation/RoyalCode.Commands.Automation.csproj index 90c9fb9..556eef1 100644 --- a/RoyalCode.EnterprisePatterns/RoyalCode.Commands.Automation/RoyalCode.Commands.Automation.csproj +++ b/RoyalCode.EnterprisePatterns/RoyalCode.Commands.Automation/RoyalCode.Commands.Automation.csproj @@ -26,8 +26,4 @@ - - - - diff --git a/RoyalCode.EnterprisePatterns/RoyalCode.Commands.Handlers/RoyalCode.Commands.Handlers.csproj b/RoyalCode.EnterprisePatterns/RoyalCode.Commands.Handlers/RoyalCode.Commands.Handlers.csproj index 9dc99d4..5b0436a 100644 --- a/RoyalCode.EnterprisePatterns/RoyalCode.Commands.Handlers/RoyalCode.Commands.Handlers.csproj +++ b/RoyalCode.EnterprisePatterns/RoyalCode.Commands.Handlers/RoyalCode.Commands.Handlers.csproj @@ -21,8 +21,4 @@ RoyalCode Enterprise-Patterns CQRS Command-Pattern - - - - diff --git a/RoyalCode.EnterprisePatterns/RoyalCode.Commands.Tests/RoyalCode.Commands.Tests.csproj b/RoyalCode.EnterprisePatterns/RoyalCode.Commands.Tests/RoyalCode.Commands.Tests.csproj index 2c59f2e..35d60cd 100644 --- a/RoyalCode.EnterprisePatterns/RoyalCode.Commands.Tests/RoyalCode.Commands.Tests.csproj +++ b/RoyalCode.EnterprisePatterns/RoyalCode.Commands.Tests/RoyalCode.Commands.Tests.csproj @@ -5,14 +5,6 @@ - - - - - - - - diff --git a/git-commit-push.bat b/git-commit-push.bat index 10d508a..d57bb30 100644 --- a/git-commit-push.bat +++ b/git-commit-push.bat @@ -1,20 +1,60 @@ @echo off +setlocal EnableExtensions echo Current folder: %cd% -for /f "delims=" %%i in ('git status --porcelain') do set status=%%i +git rev-parse --is-inside-work-tree >nul 2>&1 +if errorlevel 1 ( + echo Error: current folder is not a Git repository. + pause + exit /b 20 +) + +git status --porcelain >nul 2>&1 +if errorlevel 1 ( + echo Error: failed to read Git status. + pause + exit /b 20 +) + +set "status=" +for /f "delims=" %%i in ('git status --porcelain') do set "status=1" if not defined status ( echo There are no changes to the repository. pause - exit /b 0 + exit /b 10 ) set /p "commit_message=Enter the commit message: " +set "commit_message_check=%commit_message: =%" +if not defined commit_message_check ( + echo Error: commit message cannot be empty. + pause + exit /b 30 +) git add --all +if errorlevel 1 ( + echo Error: failed to stage files with git add --all. + pause + exit /b 40 +) + git commit -m "%commit_message%" -git push +if errorlevel 1 ( + echo Error: failed to create commit. + pause + exit /b 50 +) + +git push +if errorlevel 1 ( + echo Error: failed to push changes to remote. + pause + exit /b 60 +) echo Git operations completed successfully. echo -------------------------------------- -pause \ No newline at end of file +pause +exit /b 0 \ No newline at end of file