diff --git a/.github/workflows/smart-search.yml b/.github/workflows/smart-search.yml index 18a0b40..8a5f950 100644 --- a/.github/workflows/smart-search.yml +++ b/.github/workflows/smart-search.yml @@ -35,5 +35,8 @@ jobs: - name: Build and pack SmartSearch AspNetCore run: dotnet build ./src/RoyalCode.SmartSearch.AspNetCore/RoyalCode.SmartSearch.AspNetCore.csproj -c Release + - name: Build and pack SmartSearch AspNetCore Npgsql + run: dotnet build ./src/RoyalCode.SmartSearch.EntityFramework.Npgsql/RoyalCode.SmartSearch.EntityFramework.Npgsql.csproj -c Release + - name: Publish run: dotnet nuget push ./**/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate diff --git a/docs/problems.md b/docs/problems.md deleted file mode 100644 index b07fba6..0000000 --- a/docs/problems.md +++ /dev/null @@ -1,573 +0,0 @@ -# Documentação da API SmartProblems (Problems, Result, FindResult) - -Esta documentação apresenta os conceitos, funcionalidades e exemplos práticos para usar a biblioteca SmartProblems em projetos .NET. -Serve também como referência para ferramentas de IA (ex.: GitHub Copilot) compreenderem e gerarem código de forma correta com base na API da biblioteca. - -Projetos alvo: .NET 8, .NET 9 e .NET 10. - -## 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. - -Conceitos principais: -- `Problem`: representa um erro com categoria, detalhe, propriedade e extensões. -- `Problems`: coleção de `Problem` (encadeável, iterável, conversível para `Result`). -- `Result` / `Result`: resultado de operação (sucesso/falha), com APIs de composição/transformação. -- `FindResult` / `FindResult`: resultado de busca, com utilitários para continuar/mapear e converter para `Result`. -- Conversões para `ProblemDetails` (RFC 9457) para uso em APIs. -- Extensões para Entity Framework (métodos `TryFind*`). - -## 2. Funcionalidades Principais - -- Modelagem de erros - - Categorias: `NotFound`, `InvalidParameter`, `ValidationFailed`, `NotAllowed`, `InvalidState`, `InternalServerError`, `CustomProblem`. - - Campos: `Detail`, `Property`, `TypeId`, `Extensions`. - - Utilitários: `With(key, value)`, `ChainProperty(parent[, index])`, `ReplaceProperty(newProp)`. - -- Coleção de erros (`Problems`) - - Operadores: implícito de `Problem` para `Problems`, `+` para agregar problemas. - - Iteração, indexador, `Contains`, `CopyTo`, `Count`. - - Conversão para `Result` e para `InvalidOperationException` (`ToException(...)`). - -- Resultados (`Result`, `Result`) - - Construção implícita a partir de valor, `Problem`, `Problems`, `Exception`. - - Consultas: `IsSuccess`, `HasProblems(out problems)`, `HasValue(out value)`. - - Composição: `Match`, `Map`, `Continue`, `Collect`, variantes `Async`. - - Soma de problemas entre resultados (`+=`). - -- Busca segura (`FindResult`, `FindResult`) - - Avaliação: `Found`, `NotFound(out problem)`, `HasInvalidParameter(...)`. - - Composição: `Collect`, `Continue`, `Map` (+ `Async`). - - Conversão: `ToResult([parameterName])`. - - Fábrica: `FindResult.Problem(byName, propertyName, propertyValue)`. - -- Conversão para `ProblemDetails` - - `RoyalCode.SmartProblems.Conversions`: `Problems.ToProblemDetails(options)`. - - `ProblemDetailsExtended` agrega múltiplos problemas (`errors`, `not_found`, `inner_details`). - - Personalização via `ProblemDetailsOptions` e `ProblemDetailsDescriptor`. - -- Integrações - - Entity Framework: `SmartProblemsEFExtensions` com `TryFindAsync`/`TryFindByAsync` e `FindResult`. - - FluentValidation: `ValidationsExtensions` (`ToProblems`, `HasProblems`, `EnsureIsValid`, `Validate`/`ValidateAsync`). - - HTTP/ASP.NET: utilitários para converter para `ProblemDetails` e resultados de API. - -- Tratamento de exceções - - `Problems.InternalError(Exception?, ExceptionOptions?)` com controle de mensagem, tipo e stack trace. - - `Problems.ExceptionHandler` para mapear exceções customizadas em `Problem`. - -## 3. Exemplos de uso: Problems - -Antes dos exemplos, é importante entender que os problemas são criados por categoria, cada uma mapeando para um HTTP Status Code e um cenário recomendado de uso. A seguir, as categorias suportadas, o status associado e quando usar: - -- `InvalidParameter` → 400 Bad Request - - Quando: entrada inválida do cliente (formato, range, campos obrigatórios, enum inválido). Ideal para validações de request e regras de entrada. - - Dica: use `Property` para apontar o campo específico; agregue várias ocorrências em uma única resposta. - -- `ValidationFailed` → 422 Unprocessable Entity - - Quando: regras de domínio/negócio foram violadas embora a entrada seja sintaticamente válida (ex.: estado inconsistente, combinação inválida). Foca em validação semântica. - -- `NotAllowed` → 403 Forbidden - - Quando: operação proibida devido a autorização/política/regra (ex.: usuário sem permissão, janela de operação fechada). - -- `InvalidState` → 409 Conflict - - Quando: conflito de estado ou transição inválida (ex.: pedido já concluído, recurso bloqueado). - -- `NotFound` → 404 Not Found - - Quando: recurso não existe (ex.: ID inexistente, filtro não encontrou registro). - -- `InternalServerError` → 500 Internal Server Error - - Quando: erro inesperado no servidor (exceptions não tratadas, falha de infraestrutura). Não use para erros esperados de domínio. - -- `CustomProblem` → definido pela descrição (ProblemDetails) do seu tipo - - Quando: erro específico de domínio que não se encaixa nas categorias padrão; requer `typeId` e descrição via `ProblemDetailsOptions`. - -Separação de Custom e Exception: -- Custom: use `Problems.Custom(detail, typeId, property)` para erros de domínio descritos pela sua API. -- Exception: use `Problems.InternalError(exception)` para exceptions inesperadas; configure `ExceptionOptions` e `ExceptionHandler` se necessário. - -Exemplos por categoria: - -```csharp -// 400 Bad Request – entrada inválida -var p400 = Problems.InvalidParameter("Name is required", "name"); -var p400Range = Problems.InvalidParameter("Age must be greater than 18", "age"); - -// 422 Unprocessable Entity – regra de negócio violada -var p422 = Problems.ValidationFailed("Order total cannot be negative", "total"); -var p422Combo = Problems.ValidationFailed("Payment method not compatible with plan", "paymentMethod"); - -// 403 Forbidden – não permitido -var p403 = Problems.NotAllowed("You do not have permission to cancel this order"); -var p403Policy = Problems.NotAllowed("Action not allowed during maintenance window"); - -// 409 Conflict – estado inválido -var p409 = Problems.InvalidState("Order is already shipped"); -var p409Lock = Problems.InvalidState("Resource is locked by another process"); - -// 404 Not Found – recurso inexistente -var p404 = Problems.NotFound("User not found", "userId"); -var p404Filter = Problems.NotFound("No results for filter", "query"); - -// 500 Internal Server Error – erro inesperado -var p500 = Problems.InternalError(new Exception("Unexpected error")); -var p500Default = Problems.InternalError(); // usa mensagem padrão configurada - -// Custom – descreva seu tipo em ProblemDetails -var pCustom = Problems.Custom("Order on hold", typeId: "order-on-hold", property: "status"); -``` - -Extensões e propriedades encadeadas: - -```csharp -p400.With("attempt", 1).ChainProperty("User", 0); // User[0].name -p422.With("policy", "minimum-total"); -``` - -### Validando classes de forma padronizada - -Para validar as propriedades de uma classe pode ser criado um método HasProblems que retorna os problemas encontrados: - -```csharp -public class User -{ - public string Name { get; set; } - - public int Age { get; set; } - - public bool HasProblems([NotNullWhen(true)] out Problems? problems) - { - Problems errors = []; - - if (string.IsNullOrWhiteSpace(Name)) - errors += Problems.InvalidParameter("Name is required", "name"); - - if (Age < 18) - errors += Problems.InvalidParameter("Age must be at least 18", "age"); - - if (errors.Count > 0) - { - problems = errors; - return true; - } - - problems = null; - return false; - } -} -``` - -DICA: Use a biblioteca RoyalCode.SmartValidation para validação fluente, com RuleSet, e integrada com Problems e Results. - - -### Custom 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: -- Use um `type` estável, único e documentado, de preferência uma URI absoluta (ex.: `https://api.seu-dominio.com/problems/order-on-hold`). -- Inclua `title` humano-legível e `status` coerente ao tipo descrito. Evite títulos genéricos. -- Utilize `instance` (URI) para identificar a ocorrência específica do problema quando aplicável. -- As extensões devem usar nomes claros e estáveis; evite sobrescrever campos reservados (`type`, `title`, `status`, `detail`, `instance`). -- 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. - -Exemplo de configuração do tipo no `ProblemDetailsOptions`: -```csharp -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)); - -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 -``` - -Converter coleção de problemas para exceção: -```csharp -var ex = (p400 + p422).ToException("Validation errors: {0}"); -throw ex; -``` - -## 4. Exemplos de uso: Result - -Construção e verificação: -```csharp -Result ok = "Hello"; -Result fail = Problems.InvalidParameter("Invalid", "prop"); - -if (ok.HasValue(out var value)) { /* sucesso */ } -if (fail.HasProblems(out var errs)) { /* erros */ } -``` - -Composição síncrona e assíncrona: -```csharp -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)); - -// branch explícito: Match -var outRes = ok.Match( - value => Result.Ok(), - problems => problems.AsResult()); - -// branch assíncrono: MatchAsync -var outResAsync = await ok.MatchAsync( - value => Task.FromResult(Result.Ok()), - problems => Task.FromResult(problems.AsResult())); -``` - -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 IUserPolicy _policy; - - public Result Create(UserInput input) - { - // validação de entrada - if (input.HasProblems(out var problems)) - return problems; // 400. - - // regra de negócio - if (_validator.EnsureIsValid(input).HasProblems(out var problems)) - return problems; // 400/422 etc. - - // regra de negócio - if (!_policy.CanCreate(input)) - return Problems.NotAllowed("Not allowed to create user"); - - // persistência - _repo.Add(input); - return Result.Ok(); - } - - public Task DisableAsync(int id) - { - var findUser = _repo.FindByIdAsync(id); - if (findUser.NotFound(out var problem)) - return Task.FromResult((Result)problem); // 404 - - findUser.Entity.Disable(); - - return Result.Ok(); - } -} - -// Result com valor -public readonly struct OrderService -{ - private readonly IOrderRepository _repo; - - public Result Get(int id) - { - var found = _repo.TryFind(id); // retorna FindResult - return found.ToResult(); - } -} -``` - -Por que `Result` favorece um ótimo tratamento de erros? -- Substitui exceções em fluxo esperado por um tipo explícito de sucesso/falha, tornando o controle de fluxo transparente. -- Padroniza mensagens e categorias via `Problems`, permitindo conversão consistente para `ProblemDetails` em APIs. -- Facilita composição funcional (Map, Continue, Match), reduzindo boilerplate e melhorando legibilidade. -- Integra com validação (`FluentValidation`) e persistência (EF `FindResult`). - -Performance: `Result` é um `readonly struct` -- Structs evitam alocação de heap em cenários comuns e permitem passagem por valor eficiente. -- `readonly` garante imutabilidade e melhor otimização pelo JIT. -- Métodos marcados com `AggressiveInlining` reduzem overhead em chamadas frequentes. -- Em pipelines síncronos/assíncronos curtos, reduz GC pressure em comparação com exceções. - -Agregação de problemas: - -```csharp -Result r1 = Problems.InvalidParameter("A"); -Result r2 = Problems.InvalidParameter("B"); -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`. -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)`: - - 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`). - -- `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. - -Uso típico: -```csharp -// Buscar por Id -Id id = 4; -var entry = await db.TestEntities.TryFindAsync(id); -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 -} - -// 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" - // problemByName.Extensions: { Name: "Test4", entity: "TestEntity" } - return problemByName; -} -``` - -Quando possui nomes customizados (ex.: título do campo, alias e valor), use a sobrecarga: -```csharp -var entry2 = await db.TryFindByAsync(e => e.Name == "Test4", displayName: "Name", alias: "name", value: "Test4"); -if (entry2.NotFound(out var p)) -{ - // p.Extensions: { name: "Test4", entity: "TestEntity" } -} -``` - -Composição com `FindResult`: -```csharp -await entry.ContinueAsync(async entity => -{ - // prossiga com a entidade - return Result.Ok(); -}); -``` - -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 -var res = entry.ToResult("id"); -// Se o parâmetro "id" for inválido, retorna Problem InvalidParameter com detail e property padronizados. -``` - -## 6. Resultados de API (OkMatch, NoContentMatch, CreatedMatch) - -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) -{ - await Task.Delay(10); // simulação - - return _personService.CreatePerson(create) - .Map(person => new PersonDetails - { - Id = person.Id, - Name = person.Name, - Age = person.Age - }) - .CreatedMatch(p => $"/api/match/{p.Id}"); -} - -// GET: retorna 200 com corpo ou 404 ProblemDetails -private static async Task> GetPerson(int id) -{ - await Task.Delay(10); - - return _personService.GetPerson(id) - .Map(person => new PersonDetails - { - Id = person.Id, - Name = person.Name, - Age = person.Age - }); -} - -// PATCH: retorna 200 OK ou ProblemDetails (400/404) -private static async Task UpdatePersonName(int id, PersonUpdateName model) -{ - await Task.Delay(10); - return _personService.UpdatePersonName(id, model); -} - -// PATCH: retorna 200 OK ou ProblemDetails (400/404) -private static async Task UpdatePersonAge(int id, PersonUpdateAge model) -{ - await Task.Delay(10); - return _personService.UpdatePersonAge(id, model); -} - -// DELETE: retorna 204 ou 404 ProblemDetails -private static async Task DeletePerson(int id) -{ - await Task.Delay(10); - return _personService.DeletePerson(id); -} -``` - -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.). - -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. -- Use `instance` quando aplicável para identificar o recurso/ocorrência. - -## 7. Cliente HTTP: ToResultAsync - -Métodos `HttpResultExtensions.ToResultAsync` desserializam respostas HTTP em `Result`/`Result`: -- Em sucesso (2xx): retornam `Result.Ok()` ou `Result` com o corpo JSON. -- Em falha (4xx/5xx): lê `application/problem+json` e converte para `Problems`; se não for ProblemDetails, tenta texto puro ou leitor customizado. - -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, 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); -``` - -Exemplos reais de consumo com `HttpClient`: - -```csharp - -var http = new HttpClient { BaseAddress = new Uri("https://api.exemplo.com") }; - -// 1) GET com corpo: sucesso → Result, falha → Problems -var respGet = await http.GetAsync("/users/123"); -var userResult = await respGet.ToResultAsync(); -if (userResult.HasValue(out var user)) -{ - Console.WriteLine($"User: {user.Name}"); -} -else if (userResult.HasProblems(out var problems)) -{ - // exibir problem details - foreach (var p in problems) Console.WriteLine($"{p.Category}: {p.Detail}"); -} - -// 2) POST criação: sucesso (201) sem corpo → Result.Ok(), Location em headers -var createResp = await http.PostAsJsonAsync("/users", new { name = "John", age = 20 }); -var createResult = await createResp.ToResultAsync(); -if (createResult.IsSuccess) -{ - if (createResp.Headers.Location is Uri loc) - Console.WriteLine($"Criado em: {loc}"); -} -else if (createResult.HasProblems(out var problems)) -{ - // entrada inválida (400) ou regra semântica (422) - foreach (var p in problems) Console.WriteLine($"Erro: {p.Property} → {p.Detail}"); -} - -// 3) PATCH atualização: sucesso (200) sem corpo, falha padronizada -var patchResp = await http.PatchAsJsonAsync("/users/123/name", new { name = "Mary" }); -var patchResult = await patchResp.ToResultAsync(); -if (!patchResult.IsSuccess && patchResult.HasProblems(out var errs)) -{ - // erros como NotFound(404) ou InvalidParameter(400) - foreach (var p in errs) Console.WriteLine($"{p.Category}: {p.Detail}"); -} - -// 4) GET lista com `JsonTypeInfo` otimizado -var respList = await http.GetAsync("/users"); -var listResult = await respList.ToResultAsync(UsersContext.Default.ListUserDto); -if (listResult.HasValue(out var users)) -{ - Console.WriteLine($"Total: {users.Count}"); -} - -// 5) Falha com conteúdo não-ProblemDetails usando FailureTypeReader -var reader = new FailureTypeReader(async r => -{ - var text = await r.Content.ReadAsStringAsync(); - return new FailureTypeReaderResult(true, Problems.InternalError(text)); -}); -var respOther = await http.GetAsync("/external/service"); -var otherResult = await respOther.ToResultAsync(reader); -if (otherResult.HasProblems(out var ps)) -{ - foreach (var p in ps) Console.WriteLine(p.Detail); -} -``` - -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 - -- Padronize categorias e status HTTP: - - 404 NotFound, 400 InvalidParameter (entrada), 422 ValidationFailed (semântica), 403 NotAllowed, 409 InvalidState, 500 Internal. - - Defina tipos customizados com `Problems.Custom` e descreva em `ProblemDetailsOptions`. -- Siga o RFC 9457: - - Prefira URIs absolutas para `type` e `instance`, títulos claros (`title`) e `status` coerente. - - 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. - - 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`. -- Persistência e buscas: - - Use `TryFindAsync`/`TryFindByAsync` (EF) e trate `FindResult` com `NotFound`/`ToResult([param])`. - - 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. - - Para erros não-ProblemDetails, considere `FailureTypeReader`. -- Observabilidade e contexto: - - Use `With(key, value)` para anexar dados relevantes (ids, política aplicada, limites). - - Encadeie propriedades com `ChainProperty(parent[, index])` para apontar origem precisa. -- Performance: - - `Result` é `readonly struct`; aproveite composição leve e evite alocações desnecessárias. - -## Resumo - -SmartProblems fornece uma abordagem uniforme e eficiente para tratar sucesso e falha em operações .NET. -Com `Problem`/`Problems` você modela erros com categorias e contexto; com `Result`/`Result` você compõe fluxos sem lançar exceções em casos esperados. -A biblioteca integra-se a APIs via `ProblemDetails` (RFC 9457), ao EF via `FindResult`/`TryFind*` e ao cliente HTTP com `ToResultAsync`. -Tipos como `OkMatch`, `CreatedMatch` e `NoContentMatch` simplificam respostas HTTP consistentes. -O uso de `readonly struct` e APIs inlinadas favorece performance, e as extensões (`With`, `ChainProperty`) melhoram rastreabilidade. - -## Instruções para Ferramentas de IA (GitHub Copilot) - -Diretrizes de geração alinhadas às seções 1–7: -- 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`. - - 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`). -- Entity Framework - - Use `TryFindAsync`/`TryFindByAsync` para obter `FindResult`; converta para `Result` com `ToResult([param])`. - - 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. -- Validação - - Implemente `HasProblems(out Problems?)` ou use FluentValidation (`EnsureIsValid`, `ToProblems`) para criar `Problems`. -- 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: -- “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.” -- “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 diff --git a/docs/search.md b/docs/search.md deleted file mode 100644 index 48b95f3..0000000 --- a/docs/search.md +++ /dev/null @@ -1,276 +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/src/.docs/plans/plan-operator-expression-customization.md b/src/.ai/plans/completed/plan-operator-expression-customization.md similarity index 79% rename from src/.docs/plans/plan-operator-expression-customization.md rename to src/.ai/plans/completed/plan-operator-expression-customization.md index d8c00c4..381a158 100644 --- a/src/.docs/plans/plan-operator-expression-customization.md +++ b/src/.ai/plans/completed/plan-operator-expression-customization.md @@ -22,11 +22,26 @@ Motivacao (descoberta pela demo do SmartCommands, laboratorio vivo das libs): ## Status -**PLANEJADO, PRONTO PARA EXECUCAO. Nenhuma fase iniciada. Todas as questoes (1-9) foram decididas pelo mantenedor -(ver "Respostas" e as decisoes nas "Novas questoes").** A decisao da Questao 1 (`Like` honra curingas, `Contains` +**IMPLEMENTADO — Fases 1 a 5 CONCLUIDAS na branch `feature/operator-expression-customization`; suite 248/248 +verde (net10). Aguarda revisao do mantenedor e release.** Todas as questoes (1-9) foram decididas pelo mantenedor +(ver "Respostas" e as decisoes nas "Novas questoes"). A decisao da Questao 1 (`Like` honra curingas, `Contains` literal, defaults estaticos configuraveis) expandiu o escopo: a semantica de `Like`/`Contains` no core virou a Fase 3 propria, deslocando a factory EF para a Fase 4 e o pacote Npgsql para a Fase 5. +Notas de implementacao (desvios e achados; ver tambem "Resultado" das fases): + +- **Bug latente corrigido no `DisjunctionContext`:** os operandos eram passados invertidos ao + `CreateOperatorExpression` (gerava `"valor".Contains(e.Prop)` e `"valor" > e.Prop`), divergindo do caminho + principal das resolutions. Os testes passavam por simetria dos dados. Corrigido junto com a Fase 2 (o caminho + runtime da disjuncao agora emite igual ao caminho principal); `OrTests`/`DisjunctionTests` continuam verdes. +- **`Like` sem curinga e sem wrap = igualdade exata** (semantica do LIKE), e nao `Contains`: a frase da Questao 1 + ("se nao tem % usar direto o Contains") vale no default (wrap ligado), onde `%valor%` reduz a `Contains`; + com `Wrap = None` a fidelidade ao LIKE foi mantida para preservar a paridade com `EF.Functions.Like`. +- **Overload com escape char (`EF.Functions.Like(t, p, escape)`) avaliado e nao adotado:** o proposito do modo + Like e honrar os curingas do usuario; sem uma sintaxe de escape definida para o usuario final, o overload nao + agrega — pode ser retomado se surgir o requisito. +- O `ToUpper()` da normalizacao portavel e o sem parametro (traduzivel); `ToUpperInvariant` nao e traduzido. + Contexto relacionado (fora deste plano, ja resolvido no repo aguardando release): orderby case-insensitive (`DefaultOrderByGenerator` + `OrderByHandlersMap`) e falha de traducao de `ORDER BY` relancada como `OrderByException` (400 em vez de 500) — ver `CaseInsensitiveOrderByTests` e `SearchContractFixesTests`. @@ -109,6 +124,15 @@ O filtro poder declarar case sensitivity por propriedade, com comportamento port encontraria (ex.: "JOSE" vs "josé" — caso que hoje falha mesmo no SQLite). - Disjuncao (`NomeOrApelido`) com `Insensitive` tambem normaliza. +### Resultado - CONCLUIDA + +`CriterionCase` e `LikeWrap` em `Abstractions`; `Case`/`Wrap` no `CriterionAttribute`; overload de +`CreateOperatorExpression` com `CriterionCase` normalizando `Like`/`Contains`/`StartsWith`/`EndsWith` via +`ToUpper()` (so operandos string; `Equal` e nao-string ignorados). Nota: a normalizacao no SQLite so vale para +ASCII (o `UPPER()` do SQLite nao cobre acentos), entao o teste E2E usa divergencia de case ASCII (`nOtEbOoK`) +em vez do exemplo com acento do plano; a cobertura insensitive com acentos fica nos testes em memoria. +Testes: `CriterionCaseTests` (unitarios + in-memory + disjuncao + E2E SQLite). + ## Fase 2 - Seam de emissao (`ICriterionOperatorExpressionFactory`) ### Objetivo @@ -154,6 +178,17 @@ public readonly struct CriterionOperatorContext `null` cai no default, e que a ordem de registro decide (primeira-nao-null-vence via encapsuladora). - Disjuncao usa a factory (runtime path). +### Resultado - CONCLUIDA + +`ICriterionOperatorExpressionFactory` + `CriterionOperatorContext` (readonly struct com `Operator`, `Case`, +`Wrap`, `Negation`, acessos e `ModelType`) e a encapsuladora `CriterionOperatorExpressionFactories` no core +Linq; registrada no DI por `AddSmartSearchLinq` (via `GetServices`, ordem de registro preservada); +`DefaultSpecifierFunctionGenerator` recebe a encapsuladora e a repassa por `CriterionResolutions` ate +`DefaultOperatorCriterionResolution`, `ComplexFilterCriterionResolution` (recursao) e `JunctionProperty` → +`DisjunctionContext.Append` (runtime). De quebra, corrigiu-se a inversao de operandos do caminho da disjuncao +(ver Notas de implementacao no Status). Testes: `CriterionOperatorFactoriesTests` (customizacao, fallback null, +ordem, disjuncao runtime). + ## Fase 3 - Semantica de `Like` e `Contains` no core (Questao 1) ### Objetivo @@ -194,6 +229,19 @@ do usuario honrado, com comportamento portavel (sem EF) e defaults configuraveis com `%` no valor segue literal (escapado). - Troca do default estatico para `Contains` restaura o comportamento anterior por completo. +### Resultado - CONCLUIDA + +`CriterionDefaults` (`DefaultStringOperator` = `Like`, `WrapLikeValue` = `true`, `ResolveWrap`) no Linq +(Questao 9); `DiscoveryCriterionOperator` le o default configuravel; `LikeExpressionGenerator` com o casamento +guloso decidido na Questao 7: ancora inicial via `StartsWith` + fatiamento, segmentos do meio em ordem via +`Substring(IndexOf(seg) + len)`, ancora final via `EndsWith` **sobre a fatia restante** (o que subsume a guarda +de comprimento e impede sobreposicao), `MaxSliceOperations = 5` com degradacao para `Contains`; sem curinga e +sem wrap = igualdade exata (ver Notas de implementacao). `DefaultOperatorCriterionResolution` emite chamada ao +helper de runtime (`Apply`) para `Like` string-string quando nenhuma factory customiza; a disjuncao usa +`CreatePatternExpression` direto com o valor real. Testes: `LikePatternExpressionTests` (matriz do padrao, +corte, negacao, valor vazio), `LikeSearchTests` (E2E SQLite: curinga do usuario, `Like` vs `Contains` com "100%", +wrap None = exato, insensitive) e `LikeDefaultStringOperatorTests` (valvula de escape). + ## Fase 4 - Emissao EF relacional (`EF.Functions.Like`) ### Objetivo @@ -215,6 +263,17 @@ nativo), via factory em `RoyalCode.SmartSearch.EntityFramework` (opt-in no `AddE - E2E SQLite in-memory (o `LIKE` do SQLite honra `%`): usuario busca `jo%o` e encontra; paridade de semantica com o helper portavel da Fase 3 (mesmos casos, mesmos resultados). +### Resultado - CONCLUIDA + +`EntityFrameworkLikeExpressionFactory` no pacote EntityFramework: `Like` string-string vira +`EF.Functions.Like(target, pattern)` respeitando wrap (concat `%` em expressao, traduzivel) e `Case` +(`Insensitive` → `UPPER(...) LIKE UPPER(...)`); registro opt-in `AddEntityFrameworkLikeOperator()` +(`TryAddEnumerable`, sem duplicar). Overload com escape char avaliado e nao adotado (ver Notas). Testes: +`EntityFrameworkLikeFactoryTests` — assercoes de arvore (metodo `Like`, `Concat` no wrap, `ToUpper` no +insensitive, `Not` na negacao, null fora do escopo) + E2E SQLite de paridade com os mesmos casos da Fase 3 +(SQL confirmado: `WHERE "x"."Nome" LIKE @Concat` com `@Concat = '%Jo%o%'`). Achado de uso registrado: +`ICriteria` e fluente/stateful — uma instancia nova por consulta nos testes. + ## Fase 5 - Pacote `RoyalCode.SmartSearch.EntityFramework.Npgsql` (`EF.Functions.ILike`) ### Objetivo @@ -233,6 +292,17 @@ nativo), via factory em `RoyalCode.SmartSearch.EntityFramework` (opt-in no `AddE - Assercao da arvore gerada (sem PG no repo — Questao 4); e2e real com PG (ex.: .NET Aspire) adiado para iteracao futura, sem complicar agora. +### Resultado - CONCLUIDA + +Novo projeto/pacote `RoyalCode.SmartSearch.EntityFramework.Npgsql` (multi-target net8/9/10; +`Npgsql.EntityFrameworkCore.PostgreSQL` 8.0.11/9.0.4/10.0.0 por TFM; adicionado a solution): +`NpgsqlILikeExpressionFactory` emite `EF.Functions.ILike` para `Like` + `Insensitive` (respeitando wrap e +negacao; devolve null fora desse escopo) e `AddNpgsqlLikeOperators()` registra ILike **antes** do EF Like +(ordem primeira-nao-null). Documentacao em `smartsearch.md` (nova secao "Like, Contains e Case-Insensitive" +com a comparacao das tres estrategias para PG: `citext`/collation, `ILIKE`, `ToUpper`). Testes: +`NpgsqlILikeFactoryTests` (arvore: metodo `ILike`, wrap, negacao, null fora do escopo, ordem de registro +via encapsuladora). + ## Verificacao geral - Suite completa do repo verde (`RoyalCode.SmartSearch.Tests`). diff --git a/src/.ai/plans/plan-api-typos-e-documentacao.md b/src/.ai/plans/plan-api-typos-e-documentacao.md new file mode 100644 index 0000000..62156b0 --- /dev/null +++ b/src/.ai/plans/plan-api-typos-e-documentacao.md @@ -0,0 +1,494 @@ +# Plan: Correcao de typos de API e documentacao (`api-typos-e-documentacao`) + +## Status: CONCLUIDO + +## Progresso + +`####` **100%** - 4 de 4 fases + +| Fase | Estado | +|---|---| +| Fase 1 - Renomear Disjuction para Disjunction | Concluida | +| Fase 2 - Renomear FirstDefaultAsync | Concluida | +| Fase 3 - Documentar Projection como reservado | Concluida | +| Fase 4 - Versao, verificacao e fechamento | Concluida | + +> **Manutencao deste plano:** ao concluir as tarefas de uma fase, marque cada tarefa com `- [x]`, +> troque o **Estado** da fase para `Concluida` na tabela acima e atualize a barra de progresso +> (um caractere `#` por fase concluida, `%` e `X de N`). +> Antes de fechar uma fase, confirme que decisoes, criterios de aceite, testes e invariantes relacionados foram aplicados. + +--- + +## Contexto + +### Fontes verificadas + +- `.ai/references/template-plan/template-ai-implementation-plan.md` - define o formato exigido para planos orientados a IA. +- `RoyalCode.SmartSearch.Abstractions/DisjuctionAttribute.cs` - existe API publica `DisjuctionAttribute`. +- `RoyalCode.SmartSearch.Linq/Filtering/CriterionResolutions.cs` - a deteccao de OR por atributo usa `DisjuctionAttribute`. +- `RoyalCode.SmartSearch.Linq/Filtering/Resolutions/DisjuctionCriterionResolution.cs` - existe classe interna com o typo no nome. +- `RoyalCode.SmartSearch.Abstractions/ISearch.cs` - `ISearch` expoe `FirstDefaultAsync`. +- `RoyalCode.SmartSearch.Core/Defaults/Search.cs` - implementa `FirstDefaultAsync` para `ISearch`. +- `RoyalCode.SmartSearch.Abstractions/ResultList.cs` - `GetProjection()` existe, mas lanca `NotImplementedException`. +- `smartsearch.md` e `README.md` - documentam `[Disjuction]`. +- `Directory.Build.props` - `SearchesVer` esta em `0.10.5`. +- `dotnet test SmartSearch.sln --no-restore -v minimal` - passou em 2026-07-08 com 247 testes em `net10.0`. + +### Estado atual do codigo (verificado em 2026-07-08) + +- **API publica com typo:** `DisjuctionAttribute` esta em `RoyalCode.SmartSearch.Abstractions/DisjuctionAttribute.cs`. +- **Pipeline Linq depende do nome antigo:** `CriterionResolutions` busca `typeof(DisjuctionAttribute)` e le `Alias`. +- **Classe interna com typo:** `DisjuctionCriterionResolution` aplica os grupos OR. +- **Async entity search com typo:** `ISearch` tem `FirstDefaultAsync`, enquanto `ICriteria` e `ISearch` usam `FirstOrDefaultAsync`. +- **Projection ainda nao implementada:** `ResultList.GetProjection()` lanca `NotImplementedException`. +- **Documentacao atual usa nomes antigos:** `smartsearch.md` e `README.md` citam `[Disjuction]`. +- **Release atual:** `SearchesVer` esta em `0.10.5`. + +### Lacunas, conflitos e restricoes + +- **Mudanca quebradora de API publica:** remover `DisjuctionAttribute` e `FirstDefaultAsync` quebra consumidores, mas o pacote ainda esta em pre-1.0. +- **Sem alias de compatibilidade:** nao criar outro atributo ou metodo antigo reduz ambiguidade, mas exige atualizacao nos consumidores. +- **Projection sem design fechado:** a intencao existe, mas nao ha contrato implementado para agregacoes/projecoes extras. +- **Testes executam so em `net10.0`:** as libs compilam multi-target, mas a suite atual roda em `net10.0`. + +### Superficies impactadas a mapear + +- `RoyalCode.SmartSearch.Abstractions` - contrato publico de atributo e `ISearch`. +- `RoyalCode.SmartSearch.Core` - implementacao de `ISearch`. +- `RoyalCode.SmartSearch.Linq` - resolucao de atributos e nomes internos de disjuncao. +- `RoyalCode.SmartSearch.Tests` - usos de `[Disjuction]` e chamadas a `FirstDefaultAsync`, se houver. +- `smartsearch.md` e `README.md` - documentacao para humanos e IA. +- `Directory.Build.props` - versao `0.11.0`. + +--- + +## Objetivo + +1. Substituir `Disjuction` por `Disjunction` na API publica, codigo interno, testes e documentacao. +2. Substituir `FirstDefaultAsync` por `FirstOrDefaultAsync` em `ISearch` e na implementacao. +3. Documentar `GetProjection`/`Projections` como API reservada para funcionalidade futura, sem implementar agregacoes. +4. Preparar a alteracao para release `0.11.0` com build e testes verdes. + +## Fora de escopo + +- Implementar `GetProjection`, `Projections` ou agregacoes extras em query. +- Manter alias de compatibilidade para `[Disjuction]`. +- Manter alias de compatibilidade para `FirstDefaultAsync`. +- Corrigir outros typos publicos nao decididos, como `SearchOptions.AllItens()`. +- Criar o projeto demo. Destino: `.ai/plans/plan-smartsearch-demo.md`. + +--- + +## Decisoes fechadas + +- **DF1 - Corrigir `Disjuction` sem alias:** substituir o typo por `Disjunction`, sem criar outro atributo paralelo. Fonte: decisao humana nesta conversa. +- **DF2 - Corrigir `FirstDefaultAsync`:** trocar para `FirstOrDefaultAsync`, com `Or`, no contrato e implementacao de `ISearch`. Fonte: decisao humana nesta conversa. +- **DF3 - Projection reservada para o futuro:** documentar `GetProjection`/`Projections` como intencao futura, sem implementar comportamento nesta iteracao. Fonte: decisao humana nesta conversa. +- **DF4 - Versao de release:** preparar a mudanca como `0.11.0`. Fonte: decisao humana nesta conversa. +- **DF5 - Aceitar breaking changes pre-1.0:** aplicar as correcoes como quebra controlada por ainda estar antes da versao 1.0. Fonte: decisao humana nesta conversa. + +--- + +## Historico de decisoes + +**Fase 0 (triagem de API):** + +- **Q1 - `Disjuction` deve ser mantido com alias?** Opcoes consideradas: manter alias obsoleto ou substituir sem alias. + - **Resposta Q1.1:** substituir sem criar outro atributo, pois a versao ainda e pre-1.0. + - **Conclusao Q1:** DF1 e DF5. +- **Q2 - `GetProjection` deve ser implementado agora?** Opcoes consideradas: implementar agregacoes ou documentar como reservado. + - **Resposta Q2.1:** documentar como reservado para futuro. + - **Conclusao Q2:** DF3. +- **Q3 - `FirstDefaultAsync` deve ser corrigido?** Opcoes consideradas: manter nome atual ou trocar para `FirstOrDefaultAsync`. + - **Resposta Q3.1:** trocar para `FirstOrDefaultAsync`. + - **Conclusao Q3:** DF2. + +--- + +## Design alvo + +### Contratos e bordas + +- `DisjunctionAttribute(string alias)`: atributo publico em `RoyalCode.SmartSearch` para agrupar propriedades de filtro em OR. +- `DisjunctionCriterionResolution`: classe interna no Linq para aplicar grupos OR por atributo e por convencao de nome. +- `ISearch.FirstOrDefaultAsync(CancellationToken cancellationToken = default)`: metodo async publico para obter o primeiro item ou `null`. +- `IResultList.Projections` e `IResultList.GetProjection()`: API reservada; a documentacao deve avisar que nao ha implementacao funcional nesta versao. + +### Modelo, dados e persistencia + +```text +Nao ha mudanca de modelo, dados ou persistencia. +``` + +### Arquitetura alvo + +```text +RoyalCode.SmartSearch.Abstractions/ + DisjunctionAttribute.cs + ISearch.cs + IResultList.cs + ResultList.cs + +RoyalCode.SmartSearch.Core/ + Defaults/Search.cs + +RoyalCode.SmartSearch.Linq/ + Filtering/CriterionResolutions.cs + Filtering/Resolutions/DisjunctionCriterionResolution.cs + +RoyalCode.SmartSearch.Tests/ + testes atualizados para nomes novos + +Documentacao/ + smartsearch.md + README.md +``` + +### Seguranca, concorrencia e confiabilidade + +- A alteracao nao deve mudar semantica de filtragem OR. +- A alteracao nao deve mudar tracking, paginacao, sorting, selector, hints ou factories. +- O cache de specifiers deve continuar usando os mesmos criterios de chave existentes. + +### Compatibilidade, migracao e rollout + +- Esta e uma mudanca quebradora para consumidores que usam `[Disjuction]` ou `FirstDefaultAsync`. +- A release deve ser `0.11.0`. +- A documentacao deve chamar a mudanca como correcao de typo pre-1.0. +- Os consumidores devem migrar para `[Disjunction]` e `FirstOrDefaultAsync`. + +--- + +## Ordem de execucao + +1. **Fase 1 (Renomear Disjuction para Disjunction)** - corrige atributo, resolucao, testes e docs de OR. +2. **Fase 2 (Renomear FirstDefaultAsync)** - corrige contrato async antes da verificacao global. +3. **Fase 3 (Documentar Projection como reservado)** - evita uso indevido sem implementar agregacoes. +4. **Fase 4 (Versao, verificacao e fechamento)** - atualiza versao e valida a solucao. + +Build/test padrao: + +```powershell +dotnet build SmartSearch.sln --no-restore +dotnet test SmartSearch.sln --no-restore -v minimal +``` + +--- + +## Fase 1 - Renomear Disjuction para Disjunction + +**Depende de:** DF1, DF5. + +**Escopo:** `Abstractions`, `Linq`, `Tests`, `smartsearch.md`, `README.md`. + +**O que/como:** renomear o atributo publico e os tipos internos relacionados. Atualizar todos os usos por busca textual. Nao criar alias do nome antigo. + +**Tarefas:** + +- [x] Renomear `DisjuctionAttribute.cs` para `DisjunctionAttribute.cs`. +- [x] Renomear `DisjuctionAttribute` para `DisjunctionAttribute` e atualizar XML docs. +- [x] Renomear `DisjuctionCriterionResolution` para `DisjunctionCriterionResolution`. +- [x] Atualizar `CriterionResolutions` para buscar `DisjunctionAttribute`. +- [x] Atualizar testes que usam `[Disjuction]` para `[Disjunction]`. +- [x] Atualizar `smartsearch.md` e `README.md` para usar `[Disjunction]`. +- [x] Executar `rg -n "Disjuction"` e tratar todo resultado dentro do escopo. + +**Criterios de aceite:** nao existe ocorrencia de `Disjuction` em codigo fonte ou documentacao, exceto se houver changelog/migracao explicitando o nome removido; testes de disjuncao continuam verdes. + +**Testes:** `dotnet test SmartSearch.sln --no-restore -v minimal`. + +### Resultado da Fase 1 + +Concluida em 2026-07-08. + +Entregaveis: + +- Atributo publico renomeado para `DisjunctionAttribute`. +- Resolution interna renomeada para `DisjunctionCriterionResolution`. +- Pipeline de `CriterionResolutions` atualizado para detectar `DisjunctionAttribute`. +- Teste `DisjunctionTests` atualizado para `[Disjunction]`. +- `smartsearch.md` e `README.md` atualizados para `[Disjunction]`. + +Arquivos alterados: + +- `RoyalCode.SmartSearch.Abstractions/DisjunctionAttribute.cs`. +- `RoyalCode.SmartSearch.Linq/Filtering/CriterionResolutions.cs`. +- `RoyalCode.SmartSearch.Linq/Filtering/Resolutions/DisjunctionCriterionResolution.cs`. +- `RoyalCode.SmartSearch.Tests/DisjunctionTests.cs`. +- `smartsearch.md`. +- `README.md`. + +Decisoes aplicadas: + +- DF1. +- DF5. + +Verificacao: + +- `rg -n "Disjuction" --glob "!.ai/plans/plan-api-typos-e-documentacao.md"` nao retornou ocorrencias. +- `dotnet test SmartSearch.sln --no-restore -v minimal` passou: 247 aprovados, 0 falhas, 0 ignorados. + +Warnings observados: + +- `NU5104` em `RoyalCode.SmartSearch.AspNetCore` por pacote estavel depender de `RoyalCode.SmartProblems.ApiResults` preview. +- `CS8618` em modelos de teste de `ComplexTypeTests` para propriedades nao anulaveis sem inicializacao. + +Desvios: + +- Nenhum. + +Pendencias: + +- Fases 2 a 4 permanecem pendentes. + +--- + +## Fase 2 - Renomear FirstDefaultAsync + +**Depende de:** DF2, DF5. + +**Escopo:** `RoyalCode.SmartSearch.Abstractions/ISearch.cs`, `RoyalCode.SmartSearch.Core/Defaults/Search.cs`, testes e docs. + +**O que/como:** substituir o metodo async entity-search pelo nome correto `FirstOrDefaultAsync`, mantendo semantica e assinatura de cancellation token. + +**Tarefas:** + +- [x] Renomear `ISearch.FirstDefaultAsync` para `FirstOrDefaultAsync`. +- [x] Renomear a implementacao em `Search`. +- [x] Atualizar chamadas internas e testes, se existirem. +- [x] Atualizar documentacao para citar o nome correto quando falar de `ISearch`. +- [x] Executar `rg -n "FirstDefaultAsync"` e tratar todo resultado dentro do escopo. + +**Criterios de aceite:** `FirstDefaultAsync` nao aparece mais no codigo; `ISearch`, `ICriteria` e `ISearch` usam nomenclatura consistente para `FirstOrDefaultAsync`. + +**Testes:** `dotnet test SmartSearch.sln --no-restore -v minimal`. + +### Resultado da Fase 2 + +Concluida em 2026-07-08. + +Entregaveis: + +- `ISearch` expoe `FirstOrDefaultAsync(CancellationToken cancellationToken = default)`. +- `Search` implementa `FirstOrDefaultAsync` e delega para `IPreparedQuery.FirstOrDefaultAsync`. +- `ISearch`, `ICriteria` e `ISearch` usam nomenclatura consistente para `FirstOrDefaultAsync`. + +Arquivos alterados: + +- `RoyalCode.SmartSearch.Abstractions/ISearch.cs`. +- `RoyalCode.SmartSearch.Core/Defaults/Search.cs`. + +Decisoes aplicadas: + +- DF2. +- DF5. + +Verificacao: + +- `rg -n "FirstDefaultAsync" --glob "!.ai/plans/plan-api-typos-e-documentacao.md"` nao retornou ocorrencias. +- `dotnet test SmartSearch.sln --no-restore -v minimal` passou: 247 aprovados, 0 falhas, 0 ignorados. + +Warnings observados: + +- `NU5104` em `RoyalCode.SmartSearch.AspNetCore` por pacote estavel depender de `RoyalCode.SmartProblems.ApiResults` preview. +- `CS8618` em modelos de teste de `ComplexTypeTests` para propriedades nao anulaveis sem inicializacao. + +Desvios: + +- Nenhum. + +Pendencias: + +- Fases 3 e 4 permanecem pendentes. + +--- + +## Fase 3 - Documentar Projection como reservado + +**Depende de:** DF3. + +**Escopo:** `smartsearch.md`, `README.md`, XML docs de `IResultList`/`ResultList`. + +**O que/como:** documentar que `Projections`/`GetProjection()` sao reservados para uma funcionalidade futura de agregacoes/projecoes extras sobre a consulta filtrada, sem paginação. + +**Tarefas:** + +- [x] Atualizar XML docs de `IResultList.Projections` e `GetProjection()` para avisar que a API ainda nao tem implementacao funcional. +- [x] Atualizar XML docs de `ResultList.GetProjection()` para registrar o estado atual. +- [x] Adicionar secao curta no `smartsearch.md` sobre "Projections reservadas para futuro". +- [x] Atualizar `README.md` com o mesmo aviso ou remover incentivo implicito ao uso. +- [x] Garantir que nenhum exemplo novo chame `GetProjection()` como funcional. + +**Criterios de aceite:** uma IA lendo `smartsearch.md` entende que `GetProjection()` nao deve ser usado ainda; o build continua verde. + +**Testes:** `dotnet build SmartSearch.sln --no-restore`. + +### Resultado da Fase 3 + +Concluida em 2026-07-08. + +Entregaveis: + +- XML docs de `IResultList.Projections` e `IResultList.GetProjection()` explicitam que a API e reservada para suporte futuro. +- XML docs de `ResultList.Projections`, `ResultList.GetProjection()` e `AsyncResultList.Projections` registram que o pipeline padrao ainda nao popula/implementa essas projecoes. +- `smartsearch.md` ganhou secao "Projections / GetProjection reservados" alertando IA e humanos para nao gerar exemplos funcionais com `GetProjection()`. +- `README.md` ganhou aviso curto sobre `IResultList.Projections` e `GetProjection()` serem reservados. + +Arquivos alterados: + +- `RoyalCode.SmartSearch.Abstractions/IResultList.cs`. +- `RoyalCode.SmartSearch.Abstractions/ResultList.cs`. +- `RoyalCode.SmartSearch.Abstractions/AsyncResultList.cs`. +- `smartsearch.md`. +- `README.md`. +- `.ai/plans/plan-api-typos-e-documentacao.md`. + +Decisoes aplicadas: + +- DF3. + +Verificacao: + +- `rg -n -C 3 "GetProjection|Projections|reservad|reserved" smartsearch.md README.md RoyalCode.SmartSearch.Abstractions` confirmou os avisos nas docs e XML docs. +- `dotnet build SmartSearch.sln --no-restore` passou na execucao final: 0 erros, 0 avisos. + +Warnings observados: + +- Nenhum na execucao final do build. +- Em build anterior da fase, apareceram warnings conhecidos: `NU5104` em `RoyalCode.SmartSearch.AspNetCore` por pacote estavel depender de `RoyalCode.SmartProblems.ApiResults` preview e `CS8618` em modelos de teste de `ComplexTypeTests`. + +Desvios: + +- Tambem foi documentado `AsyncResultList.Projections`, pois a propriedade faz parte da superficie publica de result lists. + +Pendencias: + +- Fase 4 concluida posteriormente neste plano. + +--- + +## Fase 4 - Versao, verificacao e fechamento + +**Depende de:** Fase 1, Fase 2, Fase 3, DF4. + +**Escopo:** `Directory.Build.props`, solucao completa. + +**O que/como:** atualizar versao para `0.11.0`, executar verificacoes finais e registrar resultado. + +**Tarefas:** + +- [x] Atualizar `SearchesVer` para `0.11.0`. +- [x] Executar busca final por nomes removidos: `rg -n "Disjuction|FirstDefaultAsync"`. +- [x] Executar `dotnet build SmartSearch.sln --no-restore`. +- [x] Executar `dotnet test SmartSearch.sln --no-restore -v minimal`. +- [x] Registrar warnings conhecidos e novos warnings no resultado da fase. + +**Criterios de aceite:** versao `0.11.0`; build verde; testes verdes; nenhum uso indevido dos nomes removidos. + +**Testes:** comandos de build/test padrao. + +### Resultado da Fase 4 + +Concluida em 2026-07-08. + +Entregaveis: + +- `SearchesVer` atualizado para `0.11.0`. +- Build gerou pacotes `0.11.0` para os projetos empacotaveis. +- Verificacao final confirmou ausencia dos nomes removidos `Disjuction` e `FirstDefaultAsync`. +- Build e testes finais passaram. + +Arquivos alterados: + +- `Directory.Build.props`. +- `.ai/plans/plan-api-typos-e-documentacao.md`. + +Decisoes aplicadas: + +- DF4. +- DF5. + +Verificacao: + +- `rg -n "Disjuction|FirstDefaultAsync"` nao retornou ocorrencias. +- `rg -n "|0\.10\.5|0\.11\.0" Directory.Build.props pack.targets` confirmou `SearchesVer` = `0.11.0` e nenhuma ocorrencia de `0.10.5` nos arquivos verificados. +- `dotnet build SmartSearch.sln --no-restore` passou: 0 erros, 7 avisos. +- `dotnet test SmartSearch.sln --no-restore -v minimal` passou: 247 aprovados, 0 falhas, 0 ignorados. + +Warnings observados: + +- `NU5104` em `RoyalCode.SmartSearch.AspNetCore` por pacote estavel depender de `RoyalCode.SmartProblems.ApiResults` preview. +- `CS8618` em modelos de teste de `ComplexTypeTests` para propriedades nao anulaveis sem inicializacao. + +Desvios: + +- Nenhum. + +Pendencias: + +- Nenhuma neste plano. + +--- + +## Matriz de rastreabilidade + +| Objetivo | Fase(s) | Decisao(es) | Criterio(s) de aceite | Teste(s) | +|---|---|---|---|---| +| Objetivo 1 | Fase 1 | DF1, DF5 | `Disjuction` removido do codigo e docs; OR continua funcionando | `dotnet test SmartSearch.sln --no-restore -v minimal` | +| Objetivo 2 | Fase 2 | DF2, DF5 | `FirstOrDefaultAsync` exposto em `ISearch`; `FirstDefaultAsync` removido | `dotnet test SmartSearch.sln --no-restore -v minimal` | +| Objetivo 3 | Fase 3 | DF3 | docs avisam que Projection e reservada/futura | `dotnet build SmartSearch.sln --no-restore` | +| Objetivo 4 | Fase 4 | DF4 | `SearchesVer` = `0.11.0`; build/test verdes | comandos padrao | + +--- + +## Invariantes a preservar + +1. A semantica de OR por atributo e por nome/caminho contendo `Or` nao pode mudar. +2. Hints, selectors, sorting, pagination e factories de operadores nao podem mudar de comportamento. +3. `GetProjection()` nao deve ser apresentado como funcional enquanto lancar `NotImplementedException`. +4. As mudancas quebradoras devem ficar limitadas aos typos decididos. + +--- + +## Criterios globais de conclusao + +- `rg -n "Disjuction|FirstDefaultAsync"` nao retorna ocorrencias em codigo/documentacao corrente, exceto historico de migracao se criado. +- `smartsearch.md` e `README.md` orientam uso de `[Disjunction]`, `FirstOrDefaultAsync` e Projection reservada. +- `Directory.Build.props` usa `SearchesVer` = `0.11.0`. +- `dotnet test SmartSearch.sln --no-restore -v minimal` passa. + +--- + +## Riscos + +| Risco | Gatilho | Impacto | Mitigacao | Estado | +|---|---|---|---|---| +| Consumidor externo quebra ao usar `[Disjuction]` | Build de app consumidor falha apos atualizar pacote | Migracao manual obrigatoria | Documentar breaking change pre-1.0 e nome novo | Aberto | +| Consumidor externo quebra ao usar `FirstDefaultAsync` | Build de app consumidor falha apos atualizar pacote | Migracao manual obrigatoria | Documentar troca para `FirstOrDefaultAsync` | Aberto | +| Typos antigos permanecem em docs | `rg` encontra ocorrencias nao historicas | IA continua copiando nome errado | Busca final obrigatoria | Aberto | +| Projection parece funcional | Exemplo usa `GetProjection()` | Runtime com `NotImplementedException` | Aviso explicito em XML docs e Markdown | Aberto | + +--- + +## Diferidos e backlog + +- Implementar `Projections`/`GetProjection()` para agregacoes sobre consulta filtrada antes da paginacao - destino: plano futuro. +- Avaliar correcao de `SearchOptions.AllItens()` para `AllItems()` - destino: backlog de breaking changes pre-1.0. +- Criar demo WebAPI - destino: `.ai/plans/plan-smartsearch-demo.md`. + +--- + +## Referencias + +- `.ai/references/template-plan/template-ai-implementation-plan.md`. +- `smartsearch.md`. +- `README.md`. +- `RoyalCode.SmartSearch.Abstractions/DisjuctionAttribute.cs`. +- `RoyalCode.SmartSearch.Abstractions/ISearch.cs`. +- `RoyalCode.SmartSearch.Abstractions/IResultList.cs`. +- `RoyalCode.SmartSearch.Abstractions/ResultList.cs`. +- `RoyalCode.SmartSearch.Core/Defaults/Search.cs`. +- `RoyalCode.SmartSearch.Linq/Filtering/CriterionResolutions.cs`. +- `RoyalCode.SmartSearch.Linq/Filtering/Resolutions/DisjuctionCriterionResolution.cs`. +- `Directory.Build.props`. diff --git a/src/.ai/plans/plan-smartsearch-demo.md b/src/.ai/plans/plan-smartsearch-demo.md new file mode 100644 index 0000000..c869b0b --- /dev/null +++ b/src/.ai/plans/plan-smartsearch-demo.md @@ -0,0 +1,662 @@ +# Plan: Projeto demo WebAPI do SmartSearch (`smartsearch-demo`) + +## Status: CONCLUIDO - Fases 1 a 5 concluidas + +## Progresso + +`#####` **100%** - 5 de 5 fases + +| Fase | Estado | +|---|---| +| Fase 1 - Fechar escopo do demo | Concluida | +| Fase 2 - Criar projeto e infraestrutura | Concluida | +| Fase 3 - Modelar dominio, filtros e DTOs | Concluida | +| Fase 4 - Implementar endpoints de exemplo | Concluida | +| Fase 5 - Documentar e validar o demo | Concluida | + +> **Manutencao deste plano:** ao concluir as tarefas de uma fase, marque cada tarefa com `- [x]`, +> troque o **Estado** da fase para `Concluida` na tabela acima e atualize a barra de progresso +> (um caractere `#` por fase concluida, `%` e `X de N`). +> Antes de fechar uma fase, confirme que decisoes, criterios de aceite, testes e invariantes relacionados foram aplicados. + +--- + +## Contexto + +### Fontes verificadas + +- `.ai/references/template-plan/template-ai-implementation-plan.md` - define o formato exigido para planos orientados a IA. +- `SmartSearch.sln` - contem pacotes principais e projeto de testes, mas nao contem projeto demo. +- `Directory.Build.props` - libs miram `net8.0;net9.0;net10.0` via `LibTargets`/`AspTargets`. +- `RoyalCode.SmartSearch.AspNetCore/Extensions/SearchExtensions.cs` - expoe helpers `MapSearch`, `MapList`, `MapFirst` e `MapSelectFirst`. +- `RoyalCode.SmartSearch.EntityFramework/Extensions/EntityFrameworkSearchesServiceCollectionExtensions.cs` - expoe `AddEntityFrameworkSearches` e `AddEntityFrameworkLikeOperator`. +- `RoyalCode.SmartSearch.EntityFramework.Npgsql/Extensions/NpgsqlSearchesServiceCollectionExtensions.cs` - expoe `AddNpgsqlLikeOperators`. +- `smartsearch.md` - documenta uso manual de `ICriteria`, filtros, sorting, DTO, Operation Hint e operadores. +- `RoyalCode.SmartSearch.Tests/CriteriaOperationHintTests.cs` e `CriteriaUseHintsTests.cs` - mostram configuracao de Operation Hint com SQLite (`ConfigureOperationHints`, `AddIncludesHandler` com `IncludeReference`/`IncludeCollection`, e por-query `UseHints`). +- `RoyalCode.SmartSearch.Tests/ComplexTypeTests.cs` - mostra `[ComplexFilter]` sobre owned/complex type, `[Criterion("MainAddress")]`, path aninhado `[Criterion("Email.Value")]` e OR por nome `[Criterion("FirstNameOrMiddleNameOrLastName")]`, com mapeamento EF `ComplexProperty`/`OwnsOne`. +- `RoyalCode.SmartSearch.Abstractions/{ICriteria,ICriteriaOptions,ISearch,CriterionAttribute,CriterionOperator,CriterionCase,LikeWrap,DisjunctionAttribute,ComplexFilterAttribute}.cs` - superficie de API confirmada para o design do demo. +- `RoyalCode.SmartSearch.Linq/ISearchConfigurations` - expoe `AddSelector`, `AddOrderBy` e `AddSpecifier` usados na configuracao do demo. +- `dotnet test SmartSearch.sln --no-restore -v minimal` - passou em 2026-07-08 com 247 testes em `net10.0`. + +### Estado atual do codigo (verificado em 2026-07-08) + +- **Nao existe demo:** nao ha projeto `RoyalCode.SmartSearch.Demo` na solution. +- **AspNetCore esta subdocumentado:** o pacote tem helpers de endpoints, mas `smartsearch.md` so o resume como "helpers para endpoints". +- **SQLite ja e usado em testes:** o projeto de testes referencia `Microsoft.EntityFrameworkCore.Sqlite` e usa conexoes in-memory. +- **Operation Hint ja tem exemplo testado:** testes cobrem includes por hints ambiente e por `UseHints`. +- **Npgsql nao tem PostgreSQL real nos testes:** `NpgsqlILikeFactoryTests` valida arvore de expressao, nao execucao em banco real. + +### Lacunas, conflitos e restricoes + +- **Escopo do demo fechado em Fase 1:** usar somente `net10.0`, project references locais, testes HTTP em projeto dedicado e SQLite in-memory por execucao. +- **Demo nao deve virar pacote:** o projeto deve existir para documentacao e experimentacao, nao para publicacao NuGet. +- **Persistencia local:** SQLite in-memory deve ser usado sem exigir servico externo. +- **Custo de manutencao:** endpoints demais podem transformar o demo em segundo produto. + +### Superficies impactadas a mapear + +- `RoyalCode.SmartSearch.Demo` - novo projeto WebAPI de exemplo. +- `RoyalCode.SmartSearch.Demo.Tests` - novo projeto de testes HTTP do demo. +- `SmartSearch.sln` - inclusao do projeto demo. +- `Directory.Build.props` ou csproj do demo - target framework e referencias. +- `.ai/plans/plan-api-typos-e-documentacao.md` - pode alterar nomes de API usados no demo. +- `smartsearch.md`/README do demo - exemplos canonicos para IA. + +--- + +## Objetivo + +1. Criar um projeto `RoyalCode.SmartSearch.Demo` WebAPI com SQLite e seed local. +2. Demonstrar uso manual de `ICriteria` e uso dos helpers AspNetCore (`MapSearch`, `MapList`, `MapFirst`/`MapSelectFirst`). +3. Demonstrar filtros declarativos cobrindo: igualdade, range numerico/de data, `CriterionOperator.In`, `Like`/`Contains` com `CriterionCase.Insensitive` e `LikeWrap.None`, o trio de OR (OR por nome, `[Disjunction]` e a armadilha `DisableOrFromName`), `[ComplexFilter]` sobre owned type, `TargetPropertyPath` aninhado, `Negation`/`IgnoreIfIsEmpty`, sorting nomeado, os dois modelos de paginacao (`UsePages`/`FetchPage` vs `Skip`/`Take`/`SkipTake`), `UseCount(false)`, as duas formas de projecao (`Select()` por convencao + `AddSelector` e `Select(expr)`), os terminais `Exists`/`Single`/`FirstOrDefault` e Operation Hint (`ConfigureOperationHints`/`AddIncludesHandler` + `UseHints`). +4. Incluir documentacao executavel para humanos e IA, com uma **matriz de cobertura** (recurso -> filtro/endpoint -> query string -> resultado esperado) e endpoints de exemplo. +5. Validar que o demo compila e sobe sem afetar os pacotes. + +## Fora de escopo + +- UI frontend. +- Autenticacao/autorizacao. +- PostgreSQL real, Aspire ou Testcontainers. +- Migracoes EF formais para producao. +- Benchmark de performance. +- Implementacao de `GetProjection()`. +- Publicacao do demo como NuGet. + +--- + +## Perguntas ao humano + +- **Q1 - Target framework do demo:** qual TFM o projeto demo deve usar? + - **Opcoes:** + - **A)** `net10.0`, alinhado ao projeto de testes atual e SDK local. + - **B)** `net8.0`, mais conservador para consumidores LTS. + - **Impacto se nao decidir:** a Fase 2 nao deve fechar o csproj. + - **Resposta:** usar apenas `net10.0`. + - **Status:** Fechada; ver DF5. + +- **Q2 - Referencias do demo:** o demo deve referenciar projetos locais ou pacotes NuGet publicados? + - **Opcoes:** + - **A)** Project references locais, util para validar a branch atual. + - **B)** Package references, util para mostrar consumo real de release. + - **Impacto se nao decidir:** muda o csproj e o papel do demo na solucao. + - **Resposta:** usar project references locais. + - **Status:** Fechada; ver DF6. + +- **Q3 - Testes do demo:** adicionar testes com `WebApplicationFactory` para endpoints principais? + - **Opcoes:** + - **A)** Sim, criar/estender um projeto de teste para smoke tests HTTP. + - **B)** Nao agora, validar apenas build e execucao manual documentada. + - **Impacto se nao decidir:** a Fase 5 fica sem criterio automatizado de endpoint. + - **Resposta:** sim, criar projeto dedicado `RoyalCode.SmartSearch.Demo.Tests`. + - **Status:** Fechada; ver DF7. + +- **Q4 - Seed do banco:** o demo pode recriar o SQLite local no startup? + - **Opcoes:** + - **A)** Sim, apagar/recriar banco demo em ambiente Development. + - **B)** Nao, apenas criar se nao existir e aplicar seed idempotente. + - **Impacto se nao decidir:** risco de perda de dados locais ou seed duplicado. + - **Resposta:** usar sempre banco zerado com SQLite in-memory, mantendo uma conexao unica aberta por execucao para preservar o banco durante a execucao. + - **Status:** Fechada; ver DF8. + +- **Q5 - Mapeamento do `Address`:** como mapear e filtrar o `Address` para demonstrar filtro complexo? + - **Opcoes:** + - **A)** Owned type (`OwnsOne`) com `[ComplexFilter]`, filtro por path aninhado na mesma tabela. + - **B)** Entidade relacionada em tabela separada com join. + - **Impacto se nao decidir:** muda o que o exemplo de `[ComplexFilter]` demonstra e a complexidade do seed. + - **Resposta:** usar owned type com `[ComplexFilter]`, seguindo o padrao de `ComplexTypeTests.cs`. + - **Status:** Fechada; ver DF9. + +--- + +## Decisoes fechadas + +- **DF1 - Criar demo WebAPI:** criar um projeto `RoyalCode.SmartSearch.Demo` para exemplos executaveis. Fonte: decisao humana nesta conversa. +- **DF2 - Usar SQLite:** o demo deve usar SQLite como persistencia local. Fonte: decisao humana nesta conversa. +- **DF3 - Usar dominio pequeno de vendas:** usar entidades como `Customer`, `Order`, `OrderItem`, `Product` e `Address` para cobrir filtros, relacionamentos e DTOs. Fonte: proposta aceita nesta conversa. +- **DF4 - Manter demo fora dos pacotes:** o demo deve entrar na solucao como exemplo, nao como pacote NuGet. Fonte: objetivo do demo e estrutura atual de pacotes. +- **DF5 - Target framework do demo:** usar apenas `net10.0`. Fonte: decisao humana nesta conversa. +- **DF6 - Referencias locais:** o demo deve referenciar os projetos locais da solucao, nao pacotes NuGet publicados. Fonte: decisao humana nesta conversa. +- **DF7 - Testes HTTP dedicados:** criar `RoyalCode.SmartSearch.Demo.Tests` para smoke tests dos endpoints com `WebApplicationFactory`. Fonte: decisao humana nesta conversa. +- **DF8 - SQLite in-memory zerado:** usar SQLite in-memory, com banco zerado por execucao e uma conexao unica aberta durante a execucao para manter o banco em memoria. Fonte: decisao humana nesta conversa. +- **DF9 - `Address` como owned type com `[ComplexFilter]`:** mapear `Address` via `OwnsOne` e anotar o tipo/filtro com `[ComplexFilter]`, filtrando por path aninhado (`MainAddress.City` etc.), conforme `ComplexTypeTests.cs`. Fonte: decisao humana nesta conversa (Q5). +- **DF10 - Estrategia pedagogica do demo:** o demo otimiza para "um filtro/endpoint demonstra N recursos" em vez de "um endpoint por recurso", e adota tres artefatos-chave: (a) uma **matriz de cobertura** (recurso -> filtro/endpoint -> query string -> resultado esperado) como entregavel de 1a classe no README; (b) pelo menos uma consulta implementada nas **duas formas** (manual via `ICriteria` e via helper AspNetCore) lado a lado; (c) um **filtro "kitchen-sink"** (`OrderFilter`) fortemente comentado como referencia canonica de copy-paste, mantendo os demais filtros minimos. Fonte: revisao de design nesta conversa. Restringe o risco "demo grande demais". + +--- + +## Historico de decisoes + +**Fase 0 (ideia do demo):** + +- **Q0 - Criar um projeto demo antes de ampliar docs?** Opcoes consideradas: documentar apenas em Markdown ou criar demo executavel. + - **Resposta Q0.1:** criar plano para um `RoyalCode.SmartSearch.Demo` WebAPI com entidades, mapeamento, SQLite e endpoints de exemplo. + - **Conclusao Q0:** DF1, DF2, DF3. + +**Fase 1 (fechamento de escopo):** + +- **Q1 - Target framework do demo:** usar apenas `net10.0`. + - **Conclusao Q1:** DF5. +- **Q2 - Referencias do demo:** usar project references locais. + - **Conclusao Q2:** DF6. +- **Q3 - Testes do demo:** criar projeto dedicado `RoyalCode.SmartSearch.Demo.Tests`. + - **Conclusao Q3:** DF7. +- **Q4 - Seed do banco:** usar SQLite in-memory zerado por execucao com conexao unica aberta durante a execucao. + - **Conclusao Q4:** DF8. + +**Fase 1.1 (refinamento de design apos revisao da superficie de API):** + +- **Q5 - Mapeamento do `Address`:** usar owned type com `[ComplexFilter]`. + - **Conclusao Q5:** DF9. +- **Revisao de design:** ampliar cobertura de recursos (trio de OR, `In`, `[ComplexFilter]`, `TargetPropertyPath`, dois modelos de paginacao, duas formas de projecao, `Exists`/`Single`, hints via `ConfigureOperationHints`/`AddIncludesHandler`) sem inflar o escopo, via matriz de cobertura, comparacao manual/mapped e filtro kitchen-sink. + - **Conclusao:** DF10. + +--- + +## Design alvo + +### Contratos e bordas + +- `RoyalCode.SmartSearch.Demo`: projeto WebAPI de exemplo, incluido na solution. +- `RoyalCode.SmartSearch.Demo.Tests`: projeto de testes HTTP do demo, incluido na solution. +- `AppDbContext`: DbContext SQLite do demo. +- `SearchExtensions` do pacote AspNetCore: usados em endpoints declarativos com `MapSearch`, `MapList`, `MapFirst` e `MapSelectFirst`. +- `ICriteria`: usado em endpoints manuais para demonstrar fluxo sem helpers, incluindo `Exists`/`ExistsAsync`, `Single`/`SingleAsync`, `FirstOrDefault`, `Collect`/`CollectAsync` e `AsSearch().ToListAsync()`. +- `ICriteriaOptions`: `UsePages`/`FetchPage` (paginacao por pagina) vs `Skip`/`Take`/`SkipTake` (paginacao por offset) e `UseCount(false)` (proxima pagina sem total). +- `[Criterion]`: demonstrar `Operator` (`In`, `Contains`, `GreaterThanOrEqual`/`LessThanOrEqual`, `Like`), `Case=Insensitive`, `Wrap=None`, `TargetPropertyPath` aninhado, `Negation` e `IgnoreIfIsEmpty`. +- OR: os tres comportamentos - split automatico por token "Or" no nome, `[Disjunction("alias")]` explicito e `DisableOrFromName` (armadilha com nomes que contem "Or"). +- `[ComplexFilter]`: filtro sobre owned type `Address` (path aninhado `MainAddress.City`), conforme DF9. +- Projecao: `Select()` por convencao (com `AddSelector` registrado) e `Select(expr)` por expressao explicita; contraste com os helpers `MapSearch` que projetam internamente. +- Operation Hint: registro via `ConfigureOperationHints` + `AddIncludesHandler` (`IncludeReference(o => o.Customer)`, `IncludeCollection(o => o.Items)`) e uso por-query via `UseHints(OrderHints.WithCustomer, ...)`; hints nao se aplicam a `Select()` nem a `Exists`. +- `SearchExtensions` de configuracao (`ISearchConfigurations`): `Add()`, `AddSelector()` e `AddOrderBy(nome, expr)`. +- `SearchOptions` e `Sorting[]`: usados via query string para paginacao e ordenacao; `OrderBy` invalido resulta em `OrderByException` tratada pelo pipeline como problema HTTP 400. + +### Modelo, dados e persistencia + +```text +Customer + Id int key + Name string required + Email string required + MainAddress Address owned (OwnsOne) + [ComplexFilter] + +Address ([ComplexFilter], mapeado via OwnsOne na mesma tabela do Customer) + Street string + City string + State string + PostalCode string + +Product + Id int key + Sku string required + Name string required + Price decimal required + Active bool required + +Order + Id int key + Number string required + CreatedAt DateTime required + Status enum required + CustomerId int required + Customer navigation + Items collection + +OrderItem + Id int key + OrderId int required + ProductId int required + Quantity int required + UnitPrice decimal required +``` + +Persistencia do demo: + +```text +SQLite in-memory + Banco zerado por execucao + Uma conexao SQLite aberta durante a vida do host/test host + Seed deterministico aplicado no startup da aplicacao +``` + +### Arquitetura alvo + +```text +RoyalCode.SmartSearch.Demo/ + Program.cs + appsettings.json + Data/AppDbContext.cs + Data/DemoSeeder.cs + Domain/Customer.cs + Domain/Product.cs + Domain/Order.cs + Domain/OrderItem.cs + Domain/Address.cs + Filters/CustomerFilter.cs (Name Contains+Insensitive, NameOrEmail split, [Disjunction], [ComplexFilter] AddressFilter) + Filters/AddressFilter.cs ([ComplexFilter] sobre owned Address) + Filters/ProductFilter.cs (Active equal, PriceMin/PriceMax range, Sku Like+Wrap=None) + Filters/OrderFilter.cs (kitchen-sink: In statuses, range de data, Customer.Name via TargetPropertyPath, Negation, DisableOrFromName) + Dtos/CustomerDto.cs + Dtos/ProductDto.cs + Dtos/OrderSummaryDto.cs + Search/OrderHints.cs (enum de hints) + Search/OperationHintsSetup.cs (ConfigureOperationHints + AddIncludesHandler) + Endpoints/ManualSearchEndpoints.cs (ICriteria: Exists, Single, Skip/Take, UseCount(false), Select(expr), UseHints) + Endpoints/MappedSearchEndpoints.cs (MapSearch/MapList/MapFirst/MapSelectFirst; mesma consulta que um endpoint manual, p/ comparacao) + README.md (inclui a matriz de cobertura de recursos) + RoyalCode.SmartSearch.Demo.http + +RoyalCode.SmartSearch.Demo.Tests/ + DemoApplicationFactory.cs + CustomersEndpointsTests.cs + OrdersEndpointsTests.cs + ProductsEndpointsTests.cs +``` + +### Seguranca, concorrencia e confiabilidade + +- O demo nao deve expor autenticacao falsa nem regras de seguranca de producao. +- O seed deve ser deterministico. +- O SQLite in-memory deve usar uma unica conexao aberta durante a execucao do host para manter o banco vivo. +- O demo nao deve alterar arquivos fora da propria pasta, exceto solution/csproj quando necessario. + +### Compatibilidade, migracao e rollout + +- O demo nao deve alterar contratos publicos dos pacotes. +- O demo deve acompanhar os nomes corrigidos do plano `api-typos-e-documentacao`, como `[Disjunction]` e `FirstOrDefaultAsync`. +- O demo deve usar referencias locais para validar a branch atual. +- O demo deve ser excluido de empacotamento. + +--- + +## Matriz de cobertura de recursos (alvo) + +Entregavel de 1a classe (DF10), materializado no README na Fase 5. As chaves exatas de query string +sao confirmadas na Fase 4 (binding de `SearchOptions`/`Sorting[]`/filtros); ate la sao ilustrativas. + +| Recurso | Como demonstrar | Endpoint (alvo) | Query string (ilustrativa) | +|---|---|---|---| +| Igualdade | `[Criterion]` em `ProductFilter.Active` | `GET /products` | `?active=true` | +| Range numerico | `PriceMin`/`PriceMax` (`>=`/`<=`) | `GET /products` | `?priceMin=10&priceMax=100` | +| Range de data | `CreatedAtFrom`/`CreatedAtTo` em `OrderFilter` | `GET /orders` | `?createdAtFrom=2026-01-01&createdAtTo=2026-06-30` | +| `In` (lista) | `CriterionOperator.In` em `OrderFilter.Statuses` | `GET /orders` | `?statuses=Pending&statuses=Paid` | +| Like/Contains + case-insensitive | `[Criterion(Contains, Case=Insensitive)]` em `CustomerFilter.Name` | `GET /customers` | `?name=maria` | +| LikeWrap.None (ancorado) | `[Criterion(Like, Wrap=None)]` em `Sku` | `GET /products` | `?sku=ABC%` | +| OR por nome | `NameOrEmail` (split automatico) | `GET /customers` | `?nameOrEmail=maria` | +| OR por `[Disjunction]` | `[Disjunction("contato")]` em duas props | `GET /customers` | `?...` | +| Armadilha `DisableOrFromName` | prop com "Or" no nome, nao-disjuncao | README (nota) | - | +| `[ComplexFilter]` (owned Address) | `AddressFilter` -> `MainAddress` | `GET /customers` | `?city=NYC` | +| TargetPropertyPath aninhado | `[Criterion("Customer.Name")] CustomerName` | `GET /orders` | `?customerName=maria` | +| Negation | `[Criterion(Negation=true)]` | `GET /orders` | `?...` | +| Sorting nomeado | `AddOrderBy("total", ...)` | qualquer | `?sort=total desc` | +| Paginacao (UsePages) | helper padrao | `GET /orders` | `?page=1&itemsPerPage=2` | +| Paginacao (Skip/Take) | endpoint manual | `GET /manual/orders` | `?skip=20&take=20` | +| UseCount(false) | endpoint manual sem total | `GET /manual/orders` | `?count=false` | +| Projecao convencao + `AddSelector` | `MapSearch` | `GET /orders/summary` | `?...` | +| Projecao `Select(expr)` | endpoint manual | `GET /manual/orders/summary` | `?...` | +| `Exists` | endpoint manual | `GET /manual/orders/exists` | `?number=...` | +| `Single` | endpoint manual por id | `GET /manual/orders/{id}` | - | +| `FirstOrDefault` | `MapFirst`/`MapSelectFirst` | `GET /orders/first` | `?...` | +| Hint carrega navegacao | `UseHints(OrderHints.WithCustomer)` | `GET /manual/orders/{id}` | `?hints=WithCustomer` | +| Hint ignorado em DTO | `MapSearch` de DTO nao usa hint | `GET /orders/summary` | - | +| Manual vs mapped (mesma consulta) | orders por `CustomerName` + data | `GET /manual/orders` e `GET /orders` | (mesma) | +| OrderBy invalido -> 400 | pipeline `OrderByException` | qualquer | `?sort=campoInexistente` | + +--- + +## Ordem de execucao + +1. **Fase 1 (Fechar escopo do demo)** - resolve perguntas que bloqueiam csproj, seed e testes. +2. **Fase 2 (Criar projeto e infraestrutura)** - cria WebAPI, DbContext, SQLite, Swagger e wiring de DI. +3. **Fase 3 (Modelar dominio, filtros e DTOs)** - cria entidades e configuracoes SmartSearch. +4. **Fase 4 (Implementar endpoints de exemplo)** - demonstra usos manual e AspNetCore. +5. **Fase 5 (Documentar e validar o demo)** - README, `.http`, build/test e smoke checks. + +Build/test padrao: + +```powershell +dotnet build SmartSearch.sln --no-restore +dotnet test SmartSearch.sln --no-restore -v minimal +dotnet run --project .\RoyalCode.SmartSearch.Demo\RoyalCode.SmartSearch.Demo.csproj +``` + +--- + +## Fase 1 - Fechar escopo do demo + +**Depende de:** Q1, Q2, Q3, Q4. + +**Escopo:** plano e decisoes de implementacao. + +**O que/como:** obter respostas humanas para target framework, tipo de referencia, testes HTTP e seed. Atualizar `Decisoes fechadas`, `Historico de decisoes`, fases dependentes e matriz. + +**Tarefas:** + +- [x] Registrar resposta de Q1 como decisao fechada. +- [x] Registrar resposta de Q2 como decisao fechada. +- [x] Registrar resposta de Q3 como decisao fechada. +- [x] Registrar resposta de Q4 como decisao fechada. +- [x] Atualizar fases 2 a 5 se alguma decisao alterar escopo. + +**Criterios de aceite:** nao ha perguntas abertas que bloqueiem criacao do projeto, seed ou validacao. + +**Testes:** nao aplicavel; fase de decisao. + +### Resultado da Fase 1 + +Concluida em 2026-07-08. + +Entregaveis: + +- Q1 fechada: demo usara somente `net10.0`. +- Q2 fechada: demo usara project references locais. +- Q3 fechada: sera criado projeto dedicado `RoyalCode.SmartSearch.Demo.Tests`. +- Q4 fechada: demo usara SQLite in-memory, banco zerado por execucao e conexao unica aberta durante a execucao. +- Fases dependentes atualizadas para refletir target framework, referencias, testes e persistencia. + +Arquivos alterados: + +- `.ai/plans/plan-smartsearch-demo.md`. + +Decisoes aplicadas: + +- DF5. +- DF6. +- DF7. +- DF8. + +Verificacao: + +- Nao aplicavel; fase de decisao. + +Desvios: + +- Q4 foi fechada com alternativa mais especifica que as opcoes originais: SQLite in-memory, sem arquivo local. + +Pendencias: + +- Nenhuma pergunta bloqueante permanece aberta. + +--- + +## Fase 2 - Criar projeto e infraestrutura + +**Depende de:** Fase 1, DF1, DF2, DF4, DF5, DF6, DF8. + +**Escopo:** novo projeto `RoyalCode.SmartSearch.Demo`, solution, configuracao de DI e SQLite. + +**O que/como:** criar WebAPI, adicionar ao `SmartSearch.sln`, configurar SQLite, Swagger/OpenAPI, EF Core e SmartSearch. + +**Tarefas:** + +- [x] Criar projeto WebAPI `RoyalCode.SmartSearch.Demo` em `net10.0`. +- [x] Adicionar o projeto ao `SmartSearch.sln`. +- [x] Configurar project references locais para os projetos SmartSearch necessarios. +- [x] Adicionar dependencias de SQLite e Swagger se necessario. +- [x] Criar `AppDbContext` e configuracao de connection string. +- [x] Configurar `AddDbContext()` com SQLite in-memory. +- [x] Configurar conexao SQLite unica aberta durante a vida do host. +- [x] Configurar `AddEntityFrameworkSearches()`. +- [x] Configurar `AddEntityFrameworkLikeOperator()`. +- [x] Configurar seed deterministico em banco zerado por execucao. +- [x] Garantir que o demo nao gere pacote NuGet. + +**Criterios de aceite:** `dotnet build SmartSearch.sln --no-restore` compila com o novo projeto; `dotnet run --project .\RoyalCode.SmartSearch.Demo\RoyalCode.SmartSearch.Demo.csproj` sobe localmente. + +**Testes:** `dotnet build SmartSearch.sln --no-restore`. + +### Resultado da Fase 2 + +Concluida em 2026-07-08. + +Entregaveis: + +- Projeto `RoyalCode.SmartSearch.Demo` (WebAPI, `Microsoft.NET.Sdk.Web`) criado e adicionado ao `SmartSearch.sln` (pasta de solucao `Samples`). +- SQLite in-memory com conexao unica aberta (singleton) e `AddDbContext` sobre ela; `EnsureCreated` + seed deterministico no startup (DF8). +- DI de busca via `AddDemoSearches()`: `AddEntityFrameworkSearches`, `AddEntityFrameworkLikeOperator`, `ConfigureOperationHints`. +- OpenAPI via `AddOpenApi`/`MapOpenApi`; `AddProblemDetails`; raiz `/` redireciona para o documento OpenAPI. +- `IsPackable=false` (DF4); project references locais (DF6). + +Verificacao: + +- `dotnet build RoyalCode.SmartSearch.Demo` -> 0 erros, 0 avisos. +- `dotnet run` sobe em `http://localhost:5080` e serve `/openapi/v1.json` (200). + +Desvios: + +- **TFM plural obrigatorio:** `Directory.Build.props` define `AspVer`/`EFVer` condicionais a `TargetFramework`, que so resolve no inner build por-TFM. Usar `` singular quebrou o restore (NU1015); a solucao foi `net10.0` (plural), como os demais projetos. +- **Advisory Microsoft.OpenApi (NU1903):** o transitivo `Microsoft.OpenApi` 2.0.0 tem advisory; foi promovido para `2.4.0` (ainda no range) e por fim `2.10.0` (dentro da major 2.x, sem quebra), zerando avisos. + +--- + +## Fase 3 - Modelar dominio, filtros e DTOs + +**Depende de:** Fase 2, DF3, DF9, DF10. + +**Escopo:** entidades, DbContext mappings, filtros, DTOs, selectors, order-bys e hints. + +**O que/como:** criar dominio pequeno de vendas com dados suficientes para demonstrar os recursos do SmartSearch, seguindo DF10 (um filtro/endpoint demonstra N recursos). + +**Tarefas:** + +- [x] Criar entidades `Customer`, `Address`, `Product`, `Order` e `OrderItem` e o enum `OrderStatus`. +- [x] Mapear entidades no `AppDbContext`; mapear `Address` como owned via `OwnsOne` e anota-lo com `[ComplexFilter]` (DF9). +- [x] `CustomerFilter`: `Name` com `[Criterion(Contains, Case=Insensitive)]`; OR por nome (`NameOrEmail`); um par `[Disjunction("contato")]`; `[ComplexFilter] AddressFilter Address`. +- [x] `AddressFilter`: `[ComplexFilter]` sobre owned `Address`, filtrando por `City`/`State` (path aninhado). +- [x] `ProductFilter`: `Active` (igualdade), `PriceMin`/`PriceMax` (range via `GreaterThanOrEqual`/`LessThanOrEqual`), `Sku` com `[Criterion(Like, Wrap=None)]`. +- [x] `OrderFilter` (kitchen-sink, fortemente comentado): `Statuses` com `CriterionOperator.In`; `CreatedAtFrom`/`CreatedAtTo` (range de data); `CustomerName` com `[Criterion("Customer.Name")]` (TargetPropertyPath aninhado); um exemplo de `Negation`; um exemplo de `DisableOrFromName` documentando a armadilha do token "Or". +- [x] Criar DTOs `CustomerDto`, `ProductDto` e `OrderSummaryDto`. +- [x] Registrar selector por convencao via `AddSelector` (para `OrderSummaryDto`) e demonstrar `Select(expr)` explicito em um endpoint manual. +- [x] Registrar sortings nomeados via `AddOrderBy` (ex.: `total`, `createdAt`). +- [x] Criar `Search/OrderHints.cs` (enum) e `Search/OperationHintsSetup.cs` com `ConfigureOperationHints` + `AddIncludesHandler` usando `IncludeReference(o => o.Customer)` e `IncludeCollection(o => o.Items)`. +- [x] Popular seed deterministico com casos que provem: OR (nome e disjunction), case-insensitive, `In` de status, ranges, `[ComplexFilter]` de Address, sorting e paginacao. + +**Criterios de aceite:** o demo tem exemplos compilaveis para cada recurso da matriz de cobertura; seed contem dados que retornam resultados distintos para os filtros documentados. + +**Testes:** `dotnet build SmartSearch.sln --no-restore`. + +### Resultado da Fase 3 + +Concluida em 2026-07-08. + +Entregaveis: + +- Entidades `Customer`, `Address`, `Product`, `Order`, `OrderItem` e enum `OrderStatus`; `Address` owned via `OwnsOne` + `[ComplexFilter]` (DF9). +- Filtros: `CustomerFilter` (Contains+Insensitive, OR por nome, path aninhado), `AddressFilter`/`CustomerAddressFilter` (`[ComplexFilter]`), `ProductFilter` (igualdade, range, Like `Wrap=None`, `[Disjunction]`), `OrderFilter` (kitchen-sink: range de data, `Customer.Name` aninhado, Negation, `DisableOrFromName`), `OrderStatusesFilter` (`In` com `IEnumerable<>`), `OrderLookupFilters` (Equal). +- DTOs: `ProductDto` (convencao), `CustomerDto` e `OrderSummaryDto` (selectors registrados via `AddSelector`). +- `AddOrderBy` nomeados (`createdAt`, `number`, `customer`, `price`, `name`); `OrderHints` + `ConfigureOperationHints`/`AddIncludesHandler` com `IncludeReference`/`IncludeCollection`. +- Seed deterministico com 5 clientes, 5 produtos, 5 pedidos, cobrindo OR, case-insensitive, In, ranges, complex filter, sorting e paginacao. + +Verificacao: + +- `dotnet build` do demo -> 0 erros. Comportamento confirmado em runtime na Fase 4. + +Desvios: + +- **`In` exige `IEnumerable` exato:** `List`/`T[]` lancam `InvalidOperationException`. Por isso a propriedade e `IEnumerable?` e o endpoint manual recebe um array e atribui a ela. + +--- + +## Fase 4 - Implementar endpoints de exemplo + +**Depende de:** Fase 3, DF10. + +**Escopo:** endpoints Minimal API manuais e endpoints via helpers AspNetCore. + +**O que/como:** criar endpoints pequenos e nomeados, separados por grupos, com exemplos que uma IA possa copiar para outros projetos. + +**Tarefas:** + +- [x] Criar grupo `/manual/customers` usando `ICriteria` diretamente (FilterBy + Collect/ToListAsync). +- [x] Criar grupo `/manual/orders` demonstrando `UseHints`, `Select(expr)`, paginacao por `Skip`/`Take`, `UseCount(false)`, `OrderBy` e `AsSearch().ToListAsync()`. +- [x] Criar `/manual/orders/exists` (`ExistsAsync`) e `/manual/orders/{id}` (`SingleAsync`) para demonstrar os terminais e contrastar com `FirstOrDefault`. +- [x] Escolher uma consulta (ex.: orders por `CustomerName` + range de data) e implementa-la nas **duas formas** (manual e via helper) para comparacao lado a lado (DF10). +- [x] Criar endpoints via `MapSearch` para lista paginada de DTO (`UsePages`). +- [x] Criar endpoints via `MapList` para lista simples. +- [x] Criar endpoints via `MapFirst` ou `MapSelectFirst` para primeiro item. +- [x] Demonstrar query string de `SearchOptions` e `Sorting[]` e confirmar as chaves exatas de binding (fecha a coluna "query string" da matriz de cobertura). +- [x] Garantir que endpoints de DTO nao dependem de `UseHints` (hint ignorado em projecao). +- [x] Garantir que endpoints de entidade com hints carregam navegacoes esperadas (`Customer`, `Items`). +- [x] Tratar casos de 204 e erro de order by invalido (`OrderByException` -> ProblemDetails 400) pelo pipeline existente. + +**Criterios de aceite:** cada familia de endpoint tem ao menos um exemplo que retorna 200 com dados seeded; a consulta espelhada manual/mapped retorna o mesmo resultado; order by invalido retorna problema 400 quando passar pelo `Performer`. + +**Testes:** `dotnet run --project .\RoyalCode.SmartSearch.Demo\RoyalCode.SmartSearch.Demo.csproj` e chamadas manuais documentadas no `.http`. + +### Resultado da Fase 4 + +Concluida em 2026-07-08. + +Entregaveis: + +- Grupo manual `/manual/*` (`ICriteria`): `customers` (Select expr), `customers/by-address` (`[ComplexFilter]` + `Select()`), `orders` (espelho do mapeado), `orders/by-status` (`In`), `orders/page` (`Skip`/`Take` + `UseCount(false)`), `orders/exists` (`Exists`), `orders/by-number/{number}` (`Single` + hints), `orders/{id}` (`FirstOrDefault` + hints). +- Grupo mapeado (helpers): `MapSearch` (`/orders`), `MapSearch` (`/customers`), `MapList` (`/products`), `MapFirst` (`/products/first`), `MapSelectFirst` (`/customers/first`). +- Query strings de `SearchOptions` (`page`/`itemsPerPage`/`skip`/`take`/`count`) e `Sorting[]` (`orderby=-desc`) confirmadas. + +Verificacao (smoke via curl em `http://localhost:5080`, todos 200/esperado): + +- Contains+Insensitive, OR por nome, path aninhado owned, range numerico/de data, `In`, Like `Wrap=None` ancorado, `[Disjunction]`, Negation, sorting nomeado. +- `[ComplexFilter]` (`by-address`) retorna owned filtrado; hints carregam `customer`+`items` e deixam `items[].product` = null (nao pedido); `Exists` true/false; `Single` por numero unico; `FirstOrDefault` id inexistente -> 404. +- `Skip/Take` + `UseCount(false)` retorna `count:0` (nao computado). +- Order by invalido -> 400 ProblemDetails (SmartProblems) com `propertyName`/`typeName`/`pointer`. +- Endpoint manual `/manual/orders` e mapeado `/orders` retornam JSON identico para a mesma query (DF10). + +Desvios: + +- **Binding de filtros complexos:** `[AsParameters]` nao popula objetos aninhados; o exemplo de `[ComplexFilter]` fica no endpoint manual (constroi o filtro a mao). Os endpoints mapeados usam filtros planos. + +--- + +## Fase 5 - Documentar e validar o demo + +**Depende de:** Fase 4, DF7. + +**Escopo:** README do demo, arquivo `.http`, possiveis smoke tests. + +**O que/como:** criar documentacao operacional curta e validacao automatica com testes HTTP dedicados, conforme DF7. + +**Tarefas:** + +- [x] Criar `RoyalCode.SmartSearch.Demo/README.md` com objetivo, setup, endpoints e exemplos. +- [x] Incluir no README a **matriz de cobertura** (recurso -> filtro/endpoint -> query string -> resultado esperado) preenchida com as chaves confirmadas na Fase 4. +- [x] Documentar a forma do `ProblemDetails` para `OrderBy` invalido (`OrderByException` -> 400). +- [x] Criar `RoyalCode.SmartSearch.Demo/RoyalCode.SmartSearch.Demo.http` com chamadas principais (incluindo a consulta espelhada manual/mapped). +- [x] Documentar quais endpoints mostram cada recurso do SmartSearch. +- [x] Criar projeto `RoyalCode.SmartSearch.Demo.Tests`. +- [x] Adicionar testes `WebApplicationFactory` para smoke tests HTTP dos endpoints principais. +- [x] Executar `dotnet build SmartSearch.sln --no-restore`. +- [x] Executar `dotnet test SmartSearch.sln --no-restore -v minimal`. +- [x] Executar o demo e validar ao menos uma chamada por familia de endpoint. +- [x] Atualizar `smartsearch.md` para apontar o demo como referencia executavel, se desejado. + +**Criterios de aceite:** uma pessoa ou IA consegue subir o demo e chamar endpoints documentados sem ler codigo interno; build e testes passam. + +**Testes:** comandos padrao e smoke checks definidos no README/.http. + +### Resultado da Fase 5 + +Concluida em 2026-07-08. + +Entregaveis: + +- `RoyalCode.SmartSearch.Demo/README.md` com objetivo, setup, os dois modos de busca, a **matriz de cobertura** preenchida (recurso -> endpoint -> query string -> resultado, com chaves confirmadas), e notas sobre o trio de OR, `[ComplexFilter]`, hints e o `ProblemDetails` de order by invalido. +- `RoyalCode.SmartSearch.Demo.http` com chamadas para todas as familias (manual e mapeado), incluindo a consulta espelhada. +- Projeto `RoyalCode.SmartSearch.Demo.Tests` (`WebApplicationFactory`) com 19 smoke tests (customers, products, orders), incluindo a igualdade manual-vs-mapped e o 400 de order by invalido; adicionado ao `SmartSearch.sln`. + +Verificacao: + +- `dotnet build SmartSearch.sln --no-restore` -> 0 erros (7 avisos CS8618 pre-existentes no projeto de testes da lib). +- `dotnet test SmartSearch.sln --no-build` -> **266 aprovados** (247 lib + 19 demo), 0 falhas. + +Desvios: + +- **Maps estaticos globais:** `SelectorsMap`/`OrderByHandlersMap` sao singletons de processo; configurar dois hosts no mesmo processo lanca "Selector already exists". Os testes usam uma unica factory compartilhada via `ICollectionFixture` (um host por processo), o que tambem reflete o uso real (configurar uma vez). + +--- + +## Matriz de rastreabilidade + +| Objetivo | Fase(s) | Decisao(es) | Criterio(s) de aceite | Teste(s) | +|---|---|---|---|---| +| Objetivo 1 | Fase 2 | DF1, DF2, DF4, DF5, DF6, DF8 | projeto demo compila e sobe com SQLite in-memory | `dotnet build`, `dotnet run` | +| Objetivo 2 | Fase 4 | DF1 | endpoints manuais e helpers existem e retornam dados | smoke checks HTTP | +| Objetivo 3 | Fase 3, Fase 4 | DF3, DF9, DF10 | cada recurso da matriz de cobertura tem filtro/endpoint compilavel | `dotnet build`, smoke checks | +| Objetivo 4 | Fase 5 | DF7 | README, `.http` e testes HTTP cobrem os endpoints | revisao dos arquivos, smoke checks, `dotnet test` | +| Objetivo 5 | Fase 5 | DF4 | demo nao altera empacotamento dos pacotes | `dotnet build`, `dotnet test` | + +--- + +## Invariantes a preservar + +1. O demo nao pode alterar contratos publicos dos pacotes SmartSearch. +2. O demo nao pode exigir banco externo ou credenciais. +3. O demo nao pode ser empacotado como NuGet. +4. O seed deve ser deterministico e suficiente para validar os exemplos. +5. O demo deve usar nomes de API corrigidos pelo plano de typos quando esse plano for aplicado. + +--- + +## Criterios globais de conclusao + +- `RoyalCode.SmartSearch.Demo` existe na solution e compila. +- `RoyalCode.SmartSearch.Demo.Tests` existe na solution e valida endpoints principais. +- O demo sobe com SQLite in-memory e Swagger/OpenAPI. +- O README do demo explica setup e exemplos de chamadas. +- O `.http` tem chamadas para manual criteria, `MapSearch`, `MapList`, `MapFirst`/`MapSelectFirst`. +- `dotnet build SmartSearch.sln --no-restore` passa. +- `dotnet test SmartSearch.sln --no-restore -v minimal` passa. + +--- + +## Riscos + +| Risco | Gatilho | Impacto | Mitigacao | Estado | +|---|---|---|---|---| +| Demo grande demais | Muitas entidades/endpoints sem criterio | Manutencao cara | Dominio de vendas fixo (5 entidades); "um filtro/endpoint demonstra N recursos" + matriz de cobertura em vez de um endpoint por recurso | Mitigado por DF10 | +| Seed destrutivo | Startup apaga SQLite com dados locais | Perda de dados de experimentos | Usar SQLite in-memory zerado por execucao, sem arquivo local | Mitigado por DF8 | +| Demo diverge da API pre-1.0 | Plano de typos altera nomes depois do demo | Exemplos quebram | Plano de typos ja concluido antes da implementacao do demo | Mitigado | +| Sem teste HTTP | Endpoints mudam sem validacao automatica | Regressao nos endpoints nao detectada por CI | Criar `RoyalCode.SmartSearch.Demo.Tests` com `WebApplicationFactory` | Mitigado por DF7 | +| Referencias por pacote ficam desatualizadas | Pacote local nao publicado ou versao externa diverge da branch | Demo nao compila na branch | Usar project references locais | Mitigado por DF6 | + +--- + +## Diferidos e backlog + +- Adicionar demo PostgreSQL/Npgsql com `ILIKE` real - destino: plano futuro. +- Adicionar Aspire/Testcontainers para demo com multiplos providers - destino: plano futuro. +- Demonstrar `GetProjection()` quando a funcionalidade existir - destino: plano futuro de projections. +- Publicar exemplos de curl no `smartsearch.md` principal - destino: revisao de docs apos demo. + +--- + +## Referencias + +- `.ai/references/template-plan/template-ai-implementation-plan.md`. +- `SmartSearch.sln`. +- `Directory.Build.props`. +- `smartsearch.md`. +- `RoyalCode.SmartSearch.AspNetCore/Extensions/SearchExtensions.cs`. +- `RoyalCode.SmartSearch.EntityFramework/Extensions/EntityFrameworkSearchesServiceCollectionExtensions.cs`. +- `RoyalCode.SmartSearch.EntityFramework.Npgsql/Extensions/NpgsqlSearchesServiceCollectionExtensions.cs`. +- `RoyalCode.SmartSearch.Tests/CriteriaOperationHintTests.cs`. +- `RoyalCode.SmartSearch.Tests/CriteriaUseHintsTests.cs`. +- `RoyalCode.SmartSearch.Tests/ComplexTypeTests.cs`. +- `RoyalCode.SmartSearch.Abstractions/{ICriteria,ICriteriaOptions,ISearch,CriterionAttribute,CriterionOperator,CriterionCase,LikeWrap,DisjunctionAttribute,ComplexFilterAttribute}.cs`. diff --git a/src/.ai/references/problems/problems.ai-rules.md b/src/.ai/references/problems/problems.ai-rules.md new file mode 100644 index 0000000..d0d231e --- /dev/null +++ b/src/.ai/references/problems/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/src/.ai/references/problems/problems.md b/src/.ai/references/problems/problems.md new file mode 100644 index 0000000..6aca8ad --- /dev/null +++ b/src/.ai/references/problems/problems.md @@ -0,0 +1,1124 @@ +# Documentação da API SmartProblems (Problems, Result, FindResult) + +Esta documentação apresenta os conceitos, funcionalidades e exemplos práticos para usar a biblioteca SmartProblems em projetos .NET. +Serve também como referência para ferramentas de IA (ex.: GitHub Copilot) compreenderem e gerarem código de forma correta com base na API da biblioteca. + +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. + +Conceitos principais: +- `Problem`: representa um erro com categoria, detalhe, propriedade e extensões. +- `Problems`: coleção de `Problem` (encadeável, iterável, conversível para `Result`). +- `Result` / `Result`: resultado de operação (sucesso/falha), com APIs de composição/transformação. +- `FindResult` / `FindResult`: resultado de busca, com utilitários para continuar/mapear e converter para `Result`. +- 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 + - Categorias: `NotFound`, `InvalidParameter`, `ValidationFailed`, `NotAllowed`, `InvalidState`, `InternalServerError`, `CustomProblem`. + - Campos: `Detail`, `Property`, `TypeId`, `Extensions`. + - Utilitários: `With(key, value)`, `ChainProperty(parent[, index])`, `ReplaceProperty(newProp)`. + +- Coleção de erros (`Problems`) + - Operadores: implícito de `Problem` para `Problems`, `+` para agregar problemas. + - Iteração, indexador, `Contains`, `CopyTo`, `Count`. + - Conversão para `Result` e para `InvalidOperationException` (`ToException(...)`). + +- Resultados (`Result`, `Result`) + - Construção implícita a partir de valor, `Problem`, `Problems`, `Exception`. + - Consultas: `IsSuccess`, `HasProblems(out problems)`, `HasValue(out value)`. + - Composição: `Match`, `Map`, `Continue`, `Collect`, variantes `Async`. + - Soma de problemas entre resultados (`+=`). + +- Busca segura (`FindResult`, `FindResult`) + - Avaliação: `Found`, `NotFound(out problem)`, `HasInvalidParameter(...)`. + - 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` + - `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`, `ProblemDetailsDescriptor`, `ProblemDetailsDescription` e arquivos JSON. + - Página HTML de catálogo via `MapProblemDetailsDescriptionPage()`. + +- Integrações + - 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. + +- Tratamento de exceções + - `Problems.InternalError(Exception?, ExceptionOptions?)` com controle de mensagem, tipo e stack trace. + - `Problems.ExceptionHandler` para mapear exceções customizadas em `Problem`. + +## 3. Exemplos de uso: Problems + +Antes dos exemplos, é importante entender que os problemas são criados por categoria, cada uma mapeando para um HTTP Status Code e um cenário recomendado de uso. A seguir, as categorias suportadas, o status associado e quando usar: + +- `InvalidParameter` → 400 Bad Request + - Quando: entrada inválida do cliente (formato, range, campos obrigatórios, enum inválido). Ideal para validações de request e regras de entrada. + - Dica: use `Property` para apontar o campo específico; agregue várias ocorrências em uma única resposta. + +- `ValidationFailed` → 422 Unprocessable Entity + - Quando: regras de domínio/negócio foram violadas embora a entrada seja sintaticamente válida (ex.: estado inconsistente, combinação inválida). Foca em validação semântica. + +- `NotAllowed` → 403 Forbidden + - Quando: operação proibida devido a autorização/política/regra (ex.: usuário sem permissão, janela de operação fechada). + +- `InvalidState` → 409 Conflict + - Quando: conflito de estado ou transição inválida (ex.: pedido já concluído, recurso bloqueado). + +- `NotFound` → 404 Not Found + - Quando: recurso não existe (ex.: ID inexistente, filtro não encontrou registro). + +- `InternalServerError` → 500 Internal Server Error + - Quando: erro inesperado no servidor (exceptions não tratadas, falha de infraestrutura). Não use para erros esperados de domínio. + +- `CustomProblem` → definido pela descrição (ProblemDetails) do seu tipo + - Quando: erro específico de domínio que não se encaixa nas categorias padrão; requer `typeId` e descrição via `ProblemDetailsOptions`. + +Separação de Custom e Exception: +- Custom: use `Problems.Custom(detail, typeId, property)` para erros de domínio descritos pela sua API. +- Exception: use `Problems.InternalError(exception)` para exceptions inesperadas; configure `ExceptionOptions` e `ExceptionHandler` se necessário. + +Exemplos por categoria: + +```csharp +// 400 Bad Request – entrada inválida +var p400 = Problems.InvalidParameter("Name is required", "name"); +var p400Range = Problems.InvalidParameter("Age must be greater than 18", "age"); + +// 422 Unprocessable Entity – regra de negócio violada +var p422 = Problems.ValidationFailed("Order total cannot be negative", "total"); +var p422Combo = Problems.ValidationFailed("Payment method not compatible with plan", "paymentMethod"); + +// 403 Forbidden – não permitido +var p403 = Problems.NotAllowed("You do not have permission to cancel this order"); +var p403Policy = Problems.NotAllowed("Action not allowed during maintenance window"); + +// 409 Conflict – estado inválido +var p409 = Problems.InvalidState("Order is already shipped"); +var p409Lock = Problems.InvalidState("Resource is locked by another process"); + +// 404 Not Found – recurso inexistente +var p404 = Problems.NotFound("User not found", "userId"); +var p404Filter = Problems.NotFound("No results for filter", "query"); + +// 500 Internal Server Error – erro inesperado +var p500 = Problems.InternalError(new Exception("Unexpected error")); +var p500Default = Problems.InternalError(); // usa mensagem padrão configurada + +// Custom – descreva seu tipo em ProblemDetails +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 +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: + +```csharp +public class User +{ + public string Name { get; set; } + + public int Age { get; set; } + + public bool HasProblems([NotNullWhen(true)] out Problems? problems) + { + Problems errors = []; + + if (string.IsNullOrWhiteSpace(Name)) + errors += Problems.InvalidParameter("Name is required", "name"); + + if (Age < 18) + errors += Problems.InvalidParameter("Age must be at least 18", "age"); + + if (errors.Count > 0) + { + problems = errors; + return true; + } + + problems = null; + return false; + } +} +``` + +DICA: Use a biblioteca RoyalCode.SmartValidation para validação fluente, com RuleSet, e integrada com Problems e Results. + + +### 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: +- Use um `type` estável, único e documentado, de preferência uma URI absoluta (ex.: `https://api.seu-dominio.com/problems/order-on-hold`). +- Inclua `title` humano-legível e `status` coerente ao tipo descrito. Evite títulos genéricos. +- Utilize `instance` (URI) para identificar a ocorrência específica do problema quando aplicável. +- As extensões devem usar nomes claros e estáveis; evite sobrescrever campos reservados (`type`, `title`, `status`, `detail`, `instance`). +- 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` 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: "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: "tag:problemdetails/.problems#order-on-hold" +// Title: "Order on hold" +// Status: 409 +// Detail: "Order is on hold due to risk analysis" +``` + +Quando o contrato público exige uma URI absoluta, informe o `type` explicitamente: +```csharp +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: +```csharp +Result ok = "Hello"; +Result fail = Problems.InvalidParameter("Invalid", "prop"); + +if (ok.HasValue(out var value)) { /* sucesso */ } +if (fail.HasProblems(out var errs)) { /* erros */ } +``` + +Composição síncrona e assíncrona: +```csharp +var res = ok.Map(v => v.Length); // Result +var next = ok.Continue(v => Result.Ok()); // Result + +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( + value => Result.Ok(), + problems => problems.AsResult()); + +// branch assíncrono: MatchAsync +var outResAsync = await ok.MatchAsync( + value => Task.FromResult(Result.Ok()), + 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 AbstractValidator _validator; // EnsureIsValid é extensão de AbstractValidator + private readonly IUserPolicy _policy; + + public Result Create(UserInput input) + { + // validação de entrada + // 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 validationProblems)) + return validationProblems; // 400/422 etc. + + // regra de negócio + if (!_policy.CanCreate(input)) + return Problems.NotAllowed("Not allowed to create user"); + + // persistência + _repo.Add(input); + return Result.Ok(); + } + + public async Task DisableAsync(int id) + { + var findUser = await _repo.FindByIdAsync(id); + if (findUser.NotFound(out var problem)) + return problem; // 404 + + findUser.Entity.Disable(); + + return Result.Ok(); + } +} + +// Result com valor +public readonly struct OrderService +{ + private readonly IOrderRepository _repo; + + public Result Get(int id) + { + var found = _repo.TryFind(id); // retorna FindResult + return found.ToResult(); + } +} +``` + +Por que `Result` favorece um ótimo tratamento de erros? +- Substitui exceções em fluxo esperado por um tipo explícito de sucesso/falha, tornando o controle de fluxo transparente. +- Padroniza mensagens e categorias via `Problems`, permitindo conversão consistente para `ProblemDetails` em APIs. +- Facilita composição funcional (Map, Continue, Match), reduzindo boilerplate e melhorando legibilidade. +- Integra com validação (`FluentValidation`) e persistência (EF `FindResult`). + +Performance: `Result` é um `readonly struct` +- Structs evitam alocação de heap em cenários comuns e permitem passagem por valor eficiente. +- `readonly` garante imutabilidade e melhor otimização pelo JIT. +- Métodos marcados com `AggressiveInlining` reduzem overhead em chamadas frequentes. +- Em pipelines síncronos/assíncronos curtos, reduz GC pressure em comparação com exceções. + +Agregação de problemas: + +```csharp +Result r1 = Problems.InvalidParameter("A"); +Result r2 = Problems.InvalidParameter("B"); +r1 += r2; // combina problemas +``` + +## 5. Exemplos para Entidades (Id, FindResult, TryFindAsync) + +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)`, `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. + - 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>)`: + - 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`. + +- `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 +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; +} +``` + +Uso por propriedade simples: +```csharp +var byName = await db.TestEntities.TryFindByAsync(e => e.Name == "Test4", ct); + +if (byName.NotFound(out var problemByName)) +{ + // problemByName.Detail: "The record of 'The Entity for Tests' with Name 'Test4' was not found" + // problemByName.Extensions: { Name: "Test4", entity: "TestEntity" } + return problemByName; +} +``` + +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 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 +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 +var res = entry.ToResult("id"); +// Se o parâmetro "id" for inválido, retorna Problem InvalidParameter com detail e property padronizados. +``` + +## 6. Resultados de API (OkMatch, NoContentMatch, CreatedMatch) + +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) +{ + await Task.Delay(10); // simulação + + return _personService.CreatePerson(create) + .Map(person => new PersonDetails + { + Id = person.Id, + Name = person.Name, + Age = person.Age + }) + .CreatedMatch(p => $"/api/match/{p.Id}"); +} + +// GET: retorna 200 com corpo ou 404 ProblemDetails +private static async Task> GetPerson(int id) +{ + await Task.Delay(10); + + return _personService.GetPerson(id) + .Map(person => new PersonDetails + { + Id = person.Id, + Name = person.Name, + Age = person.Age + }); +} + +// PATCH: retorna 200 OK ou ProblemDetails (400/404) +private static async Task UpdatePersonName(int id, PersonUpdateName model) +{ + await Task.Delay(10); + return _personService.UpdatePersonName(id, model); +} + +// PATCH: retorna 200 OK ou ProblemDetails (400/404) +private static async Task UpdatePersonAge(int id, PersonUpdateAge model) +{ + await Task.Delay(10); + return _personService.UpdatePersonAge(id, model); +} + +// DELETE: retorna 204 ou 404 ProblemDetails +private static async Task DeletePerson(int id) +{ + await Task.Delay(10); + return _personService.DeletePerson(id); +} +``` + +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. +- Use `instance` quando aplicável para identificar o recurso/ocorrência. + +## 7. Cliente HTTP: ToResultAsync + +Métodos `HttpResultExtensions.ToResultAsync` desserializam respostas HTTP em `Result`/`Result`: +- Em sucesso (2xx): retornam `Result.Ok()` ou `Result` com o corpo JSON. +- Em falha (4xx/5xx): lê `application/problem+json` e converte para `Problems`; se não for ProblemDetails, tenta texto puro ou leitor customizado. + +Assinaturas principais: +```csharp +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? 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`: + +```csharp + +var http = new HttpClient { BaseAddress = new Uri("https://api.exemplo.com") }; + +// 1) GET com corpo: sucesso → Result, falha → Problems +var respGet = await http.GetAsync("/users/123"); +var userResult = await respGet.ToResultAsync(); +if (userResult.HasValue(out var user)) +{ + Console.WriteLine($"User: {user.Name}"); +} +else if (userResult.HasProblems(out var problems)) +{ + // exibir problem details + foreach (var p in problems) Console.WriteLine($"{p.Category}: {p.Detail}"); +} + +// 2) POST criação: sucesso (201) sem corpo → Result.Ok(), Location em headers +var createResp = await http.PostAsJsonAsync("/users", new { name = "John", age = 20 }); +var createResult = await createResp.ToResultAsync(); +if (createResult.IsSuccess) +{ + if (createResp.Headers.Location is Uri loc) + Console.WriteLine($"Criado em: {loc}"); +} +else if (createResult.HasProblems(out var problems)) +{ + // entrada inválida (400) ou regra semântica (422) + foreach (var p in problems) Console.WriteLine($"Erro: {p.Property} → {p.Detail}"); +} + +// 3) PATCH atualização: sucesso (200) sem corpo, falha padronizada +var patchResp = await http.PatchAsJsonAsync("/users/123/name", new { name = "Mary" }); +var patchResult = await patchResp.ToResultAsync(); +if (!patchResult.IsSuccess && patchResult.HasProblems(out var errs)) +{ + // erros como NotFound(404) ou InvalidParameter(400) + foreach (var p in errs) Console.WriteLine($"{p.Category}: {p.Detail}"); +} + +// 4) GET lista com `JsonTypeInfo` otimizado +var respList = await http.GetAsync("/users"); +var listResult = await respList.ToResultAsync(UsersContext.Default.ListUserDto); +if (listResult.HasValue(out var users)) +{ + Console.WriteLine($"Total: {users.Count}"); +} + +// 5) Falha com conteúdo não-ProblemDetails usando FailureTypeReader +var reader = new FailureTypeReader(async r => +{ + var text = await r.Content.ReadAsStringAsync(); + return new FailureTypeReaderResult(true, Problems.InternalError(text)); +}); +var respOther = await http.GetAsync("/external/service"); +var otherResult = await respOther.ToResultAsync(reader); +if (otherResult.HasProblems(out var ps)) +{ + foreach (var p in ps) Console.WriteLine(p.Detail); +} +``` + +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. 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. + - Defina tipos customizados com `Problems.Custom` e descreva em `ProblemDetailsOptions`. +- Siga o RFC 9457: + - Prefira URIs absolutas para `type` e `instance`, títulos claros (`title`) e `status` coerente. + - 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`/`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. + - Para erros não-ProblemDetails, considere `FailureTypeReader`. +- Observabilidade e contexto: + - Use `With(key, value)` para anexar dados relevantes (ids, política aplicada, limites). + - 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 + +SmartProblems fornece uma abordagem uniforme e eficiente para tratar sucesso e falha em operações .NET. +Com `Problem`/`Problems` você modela erros com categorias e contexto; com `Result`/`Result` você compõe fluxos sem lançar exceções em casos esperados. +A biblioteca integra-se a APIs via `ProblemDetails` (RFC 9457), ao EF via `FindResult`/`TryFind*` e ao cliente HTTP com `ToResultAsync`. +Tipos como `OkMatch`, `CreatedMatch` e `NoContentMatch` simplificam respostas HTTP consistentes. +O uso de `readonly struct` e APIs inlinadas favorece performance, e as extensões (`With`, `ChainProperty`) melhoram rastreabilidade. + +## Instruções para Ferramentas de IA (GitHub Copilot) + +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`. + - 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. +- Validação + - Implemente `HasProblems(out Problems?)` ou use FluentValidation (`EnsureIsValid`, `ToProblems`) para criar `Problems`. +- Performance e observabilidade + - Prefira `Result` (`readonly struct`) para menor alocação; use `With`/`Extensions` para dados de diagnóstico. + +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`.” diff --git a/src/.ai/references/problems/validations.ai-rules.md b/src/.ai/references/problems/validations.ai-rules.md new file mode 100644 index 0000000..fada0e6 --- /dev/null +++ b/src/.ai/references/problems/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/docs/validations.md b/src/.ai/references/problems/validations.md similarity index 67% rename from docs/validations.md rename to src/.ai/references/problems/validations.md index 7416d48..f2aa64b 100644 --- a/docs/validations.md +++ b/src/.ai/references/problems/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/src/.ai/references/template-plan/template-ai-implementation-plan.md b/src/.ai/references/template-plan/template-ai-implementation-plan.md new file mode 100644 index 0000000..f7bdb08 --- /dev/null +++ b/src/.ai/references/template-plan/template-ai-implementation-plan.md @@ -0,0 +1,296 @@ +# Template: Plano de implementação orientado a IA + +## Comandos para a IA geradora + +- Gere um plano em Markdown no arquivo `.ai/plans/plan-.md`. +- Use o português do repositório. +- Escreva o plano para outra IA executar e manter. +- Use comandos, decisões, critérios, tarefas e verificações. +- Não use linguagem aspiracional. +- Não inclua justificativas longas. +- Não inclua descrição narrativa do processo de criação do plano. +- Não invente decisões humanas. +- Marque decisões ausentes como pergunta aberta. +- Questione o humano antes de fechar decisão que altere arquitetura, contrato público, persistência, segurança, compatibilidade, CI/CD, UX pública, custo operacional ou escopo. +- Leia os artefatos de referência antes de preencher `Contexto`, `Estado atual`, `Decisões fechadas`, `Design alvo`, `Fases`, `Invariantes`, `Riscos` e `Referências`. +- Registre apenas fatos verificados em `Estado atual do código`. +- Registre incertezas em `Perguntas ao humano`, não em `Decisões fechadas`. +- Separe `Decisões fechadas` de `Histórico de decisões`. +- Use `DF` para decisões fechadas. +- Use `Q` para perguntas ao humano. +- Quando uma decisão substituir outra, mantenha o histórico com `SUPERSEDED`. +- Crie fases entregáveis, executáveis e verificáveis. +- Cada fase deve ter `Depende de`, `O que/como`, `Tarefas`, `Critérios de aceite`, `Testes` e `Resultado da Fase`. +- Cada tarefa deve começar com verbo de ação. +- Cada tarefa deve ser marcada com `- [ ]` ou `- [x]`. +- Cada critério de aceite deve ser falsificável. +- Cada comando de teste deve ser executável no repositório alvo. +- Inclua comandos de build/test padrão quando houver. +- Inclua invariantes que não podem ser quebrados durante a execução. +- Inclua riscos com gatilho, impacto e mitigação. +- Inclua rastreabilidade entre objetivos, fases, decisões e testes. +- Inclua diferidos/backlog para itens fora do escopo que foram encontrados durante o design. +- Atualize o status, a barra de progresso e a tabela de fases sempre que uma fase for concluída. +- Não marque uma fase como concluída se houver decisão aberta, critério não atendido ou teste obrigatório não executado. +- Ao concluir uma fase, preencha `Resultado da Fase` com entregáveis, arquivos alterados, desvios, verificação e pendências. +- Ao validar o plano, confira se toda decisão citada por uma fase existe em `Decisões fechadas` ou `Histórico de decisões`. + +## Shape do documento gerado + +````markdown +# Plan: (``) + +## Status: - + +## Progresso + +`` **%** - de fases + +| Fase | Estado | +|---|---| +| Fase 1 - | | +| Fase 2 - | | +| Fase N - | | + +> **Manutenção deste plano:** ao concluir as tarefas de uma fase, marque cada tarefa com `- [x]`, +> troque o **Estado** da fase para `Concluida` na tabela acima e atualize a barra de progresso +> (um bloco `█` por fase concluída, `%` e `X de N`). Exemplo de barra: `████░░░░░░░░`. +> Antes de fechar uma fase, confirme que decisões, critérios de aceite, testes e invariantes relacionados foram aplicados. + +--- + +## Contexto + +### Fontes verificadas + +- . +- . + +### Estado atual do código (verificado em ) + +- **:** . +- **:** . + +### Lacunas, conflitos e restrições + +- **:** . +- **:** . + +### Superfícies impactadas a mapear + +- `` — . +- `` — . + +--- + +## Objetivo + +1. . +2. . +3. . + +## Fora de escopo + +- . +- . + +--- + +## Perguntas ao humano + +> Remova esta seção quando não houver perguntas abertas. + +- **Q1 — :** . + - **Opções:** + - **A)** . + - **B)** . + - **Impacto se não decidir:** . + - **Status:** Aberta. + +--- + +## Decisões fechadas + +- **DF1 — :** . Fonte: . +- **DF2 — :** . Fonte: . + +--- + +## Histórico de decisões + +> Mantenha esta seção quando houver perguntas respondidas, alternativas descartadas ou decisões substituídas. + +**Fase ():** + +- **Q1 — :** . + - **Resposta Q1.1:** . + - **Considerações Q1.1:** . + - **Conclusão Q1:** . + - **SUPERSEDED por Q1.2:** , quando aplicável. + +--- + +## Design alvo + +### Contratos e bordas + +- ``: . +- ``: . + +### Modelo, dados e persistência + +```text + + + + index/unique +``` + +### Arquitetura alvo + +```text +/ + + +/ + +``` + +### Segurança, concorrência e confiabilidade + +- . +- . + +### Compatibilidade, migração e rollout + +- . +- . + +--- + +## Ordem de execução + +1. **Fase 1 ()** — . +2. **Fase 2 ()** — . +3. **Fase N ()** — . + +Build/test padrão: + +```powershell + + +``` + +--- + +## Fase 1 - + +**Depende de:** . + +**Escopo:** . + +**O que/como:** . + +**Tarefas:** + +- [ ] . +- [ ] . +- [ ] . + +**Critérios de aceite:** . + +**Testes:** . + +### Resultado da Fase 1 + +*a preencher* + +--- + +## Fase 2 - + +**Depende de:** . + +**Escopo:** . + +**O que/como:** . + +**Tarefas:** + +- [ ] . +- [ ] . +- [ ] . + +**Critérios de aceite:** . + +**Testes:** . + +### Resultado da Fase 2 + +*a preencher* + +--- + +## Matriz de rastreabilidade + +| Objetivo | Fase(s) | Decisão(es) | Critério(s) de aceite | Teste(s) | +|---|---|---|---|---| +| Objetivo 1 | Fase | DF | | | +| Objetivo 2 | Fase | DF | | | + +--- + +## Invariantes a preservar + +1. . +2. . +3. . + +--- + +## Critérios globais de conclusão + +- . +- . +- . + +--- + +## Riscos + +| Risco | Gatilho | Impacto | Mitigação | Estado | +|---|---|---|---|---| +| | | | | | +| | | | | | + +--- + +## Diferidos e backlog + +- — destino: . +- — destino: . + +--- + +## Referências + +- . +- . +```` + +## Comandos de manutenção para a IA executora + +- Antes de iniciar uma fase, leia `Depende de`, `Decisões fechadas`, `Histórico de decisões`, `Invariantes a preservar`, `Critérios globais de conclusão` e `Riscos`. +- Antes do primeiro edit de uma fase, verifique as fontes citadas e atualize `Estado atual do código` se estiver divergente. +- Ao encontrar decisão ausente, pare a fase, registre `Q` em `Perguntas ao humano` e marque a fase como `Bloqueada`. +- Ao implementar tarefa, marque `- [x]` apenas depois de validar o comportamento ou registrar a impossibilidade de validação. +- Ao alterar escopo, registre o desvio em `Resultado da Fase` e, se necessário, em `Diferidos e backlog`. +- Ao concluir fase, atualize `Resultado da Fase` com: + - entregáveis; + - arquivos/projetos alterados; + - decisões aplicadas; + - testes executados; + - desvios; + - pendências. +- Ao concluir fase, atualize `Status`, `Progresso`, tabela de fases e `Matriz de rastreabilidade`. +- Ao concluir o plano, garanta que `Critérios globais de conclusão` estejam atendidos e que `Perguntas ao humano` esteja vazia ou explicitamente diferida. \ No newline at end of file diff --git a/docs/archtecture.md b/src/.docs/archtecture.md similarity index 100% rename from docs/archtecture.md rename to src/.docs/archtecture.md diff --git a/src/.docs/smartsearch.ai-rules.md b/src/.docs/smartsearch.ai-rules.md new file mode 100644 index 0000000..55adcec --- /dev/null +++ b/src/.docs/smartsearch.ai-rules.md @@ -0,0 +1,659 @@ +# 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. +14. `Select()` resolve a projeção nesta ordem: selector já resolvido; `ISelector` no DI (inclui `cfg.AddSelector`); propriedade `public static` do DTO do tipo `Expression>`; geração por reflexão em runtime. Sem nenhuma delas, lança `SelectorNotFoundException` — erro de runtime, não de compilação. +15. Com SmartSelector no projeto, um DTO `[AutoSelect]` já expõe essa propriedade estática e é encontrado sozinho: não registre selector para ele. As libs não dependem uma da outra; cada uma funciona sozinha. + +## 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/src/.docs/smartsearch.md b/src/.docs/smartsearch.md new file mode 100644 index 0000000..186767b --- /dev/null +++ b/src/.docs/smartsearch.md @@ -0,0 +1,1176 @@ +# 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. + +### 11.3 Como o selector é resolvido + +Quando `Select()` precisa de uma expressão de projeção, o `ISelectorFactory` procura por ela em quatro +níveis, do mais explícito ao mais implícito. O primeiro que responder vence, e o resultado fica em cache para o par +`(TEntity, TDto)`: + +| # | Origem | Como | +|---|---|---| +| 1 | Selector já resolvido | cache interno do par `(TEntity, TDto)` | +| 2 | `ISelector` no DI | registro explícito da aplicação, inclusive via `cfg.AddSelector(expression)` | +| 3 | **Propriedade estática no DTO** | qualquer propriedade `public static` do DTO cujo tipo seja `Expression>` | +| 4 | Geração em runtime | expressão construída por reflexão a partir da forma dos tipos | + +O **nível 3** é um contrato por convenção, e é o que permite ao SmartSearch aproveitar uma expressão pronta sem +precisar conhecer quem a escreveu. Serve tanto para uma expressão escrita à mão: + +```csharp +public class OrderDto +{ + // descoberta pelo nível 3 — o nome da propriedade não importa, só o tipo + public static Expression> Selector { get; } = o => new OrderDto + { + Id = o.Id, + CustomerName = o.Customer.Name + }; +} +``` + +...quanto para a expressão **gerada pelo SmartSelector** (ver 11.4). + +O **nível 4** é o fallback: resolve DTOs de forma "plana", casando propriedades por nome (com flattening), e cobre +nullables, enums, subobjetos e coleções. Exige que o DTO tenha construtor sem parâmetros. Quando não consegue montar a +projeção, a execução lança `SelectorNotFoundException` — o erro aparece **em runtime**, não em compilação. + +### 11.4 Uso em conjunto com o SmartSelector + +As duas bibliotecas foram feitas para trabalhar juntas **sem depender uma da outra**: o SmartSearch funciona sem o +SmartSelector (usando os níveis 2 e 4), e o SmartSelector funciona sem o SmartSearch (projetando com LINQ direto). + +Quando as duas estão presentes, a integração é automática e não exige registro nenhum. O SmartSelector gera, no próprio +DTO, uma propriedade `public static Expression> Select{Entity}Expression` — que é exatamente o que o +**nível 3** procura: + +```csharp +[AutoSelect, AutoProperties] +public partial class OrderDetails +{ + public List Items { get; set; } = []; +} + +// SelectOrderExpression é gerada pelo SmartSelector e encontrada pelo SmartSearch: +var result = await criteria.FilterBy(filter).Select().ToListAsync(ct); +``` + +Vale a pena, porque a expressão gerada é verificada **em tempo de compilação**: o SmartSelector reporta projeções +inseguras (nulabilidade, caminhos inválidos) como diagnósticos `RCSS*`, enquanto a geração em runtime (nível 4) apenas +projeta com o que encontra. Preferir o SmartSelector quando o DTO existe no código-fonte; deixar o nível 4 para os +casos em que não há DTO anotado. + +## 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/src/Directory.Build.props b/src/Directory.Build.props index 5f7af20..0442c38 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -4,24 +4,27 @@ net8.0;net9.0;net10.0 - 0.10.5 + 0.11.1 1.0.3 1.0.0 - 1.0.0-preview-6.0 + 1.0.0-preview-8.0 8.0.2 8.0.10 + 8.0.11 9.0.0 9.0.0 + 9.0.4 10.0.0 10.0.0 + 10.0.0 diff --git a/src/README.md b/src/README.md index 3aeb4ce..e5cf09b 100644 --- a/src/README.md +++ b/src/README.md @@ -61,6 +61,12 @@ ResultList result = ...; var items = result.Items; ``` +`IResultList.Projections` and `GetProjection()` are reserved for future +query-level projections or aggregates, such as a sum over the filtered query +before paging is applied. Built-in result lists do not populate or implement +them yet; use `Items`, `Count`, `Page`, `Pages`, and related metadata for current +code. + ### Search Configuration Use ISearchConfigurations to configure filters, sorting, and selectors: @@ -74,13 +80,13 @@ services.AddEntityFrameworkSearches(cfg => ``` ### 6. Disjunction filters (grouped OR) -Use `[Disjuction("alias")]` to group multiple filter properties into an OR clause: +Use `[Disjunction("alias")]` to group multiple filter properties into an OR clause: ```csharp public class DisjunctionFilter { - [Disjuction("g1")] public string? P1 { get; set; } - [Disjuction("g1")] public string? P2 { get; set; } + [Disjunction("g1")] public string? P1 { get; set; } + [Disjunction("g1")] public string? P2 { get; set; } } // When both are empty: no WHERE is applied. @@ -163,7 +169,7 @@ public class UserFilter Behavior notes: - Empty/null values are ignored when `IgnoreIfIsEmpty` applies (strings blank, nullables not set, empty collections). - Complex filters support AND across provided subfields; only non-empty subfields are applied. -- OR semantics can be declared via `[Disjuction]` groups or inferred from `Or` in names/paths. +- OR semantics can be declared via `[Disjunction]` groups or inferred from `Or` in names/paths. ### 9. FilterExpressionGenerator for complex filter logic Use `[FilterExpressionGenerator]` to delegate expression creation to a custom generator that implements `ISpecifierExpressionGenerator`. diff --git a/src/RoyalCode.SmartSearch.Abstractions/AsyncResultList.cs b/src/RoyalCode.SmartSearch.Abstractions/AsyncResultList.cs index 27d5f11..af8a406 100644 --- a/src/RoyalCode.SmartSearch.Abstractions/AsyncResultList.cs +++ b/src/RoyalCode.SmartSearch.Abstractions/AsyncResultList.cs @@ -32,9 +32,14 @@ public sealed class AsyncResultList : IAsyncResultList [JsonConverter(typeof(SortingsConverter))] public IReadOnlyList Sortings { get; init; } = null!; - /// + /// + /// Reserved for future query-level projections computed during the async search. + /// + /// + /// The default search pipeline does not populate this property yet. + /// public Dictionary Projections { get; init; } = null!; /// public IAsyncEnumerable Items { get; init; } = null!; -} \ No newline at end of file +} diff --git a/src/RoyalCode.SmartSearch.Abstractions/CriterionAttribute.cs b/src/RoyalCode.SmartSearch.Abstractions/CriterionAttribute.cs index 2e77651..40c49db 100644 --- a/src/RoyalCode.SmartSearch.Abstractions/CriterionAttribute.cs +++ b/src/RoyalCode.SmartSearch.Abstractions/CriterionAttribute.cs @@ -53,6 +53,30 @@ public CriterionAttribute(string targetPropertyPath, CriterionOperator criterion /// public CriterionOperator Operator { get; set; } + /// + /// + /// The intended case sensitivity for string operators + /// (, , + /// , ). + /// + /// + /// Ignored for non-string operators. See . + /// + /// + public CriterionCase Case { get; set; } + + /// + /// + /// Overrides, for this criterion, whether the value is wrapped + /// with wildcards (%value%) before matching. When , the global + /// default is used (CriterionDefaults.WrapLikeValue, in the Linq package). + /// + /// + /// Ignored for operators other than . See . + /// + /// + public LikeWrap Wrap { get; set; } + /// /// /// Requires the use of the Not operator diff --git a/src/RoyalCode.SmartSearch.Abstractions/CriterionCase.cs b/src/RoyalCode.SmartSearch.Abstractions/CriterionCase.cs new file mode 100644 index 0000000..2c26c79 --- /dev/null +++ b/src/RoyalCode.SmartSearch.Abstractions/CriterionCase.cs @@ -0,0 +1,40 @@ +namespace RoyalCode.SmartSearch; + +/// +/// +/// Declares the intended case sensitivity of a criterion applied over string values. +/// +/// +/// Used by for the string operators +/// (, , +/// , ). +/// For non-string operators the value is ignored. +/// +/// +public enum CriterionCase +{ + /// + /// + /// No case handling is declared: the comparison behavior is determined by the emission strategy + /// and by the query provider (e.g. database collation). + /// + /// + Default = 0, + + /// + /// + /// The comparison is intended to be case-sensitive. The default emission applies no normalization, + /// therefore the effective behavior still depends on the provider collation. + /// + /// + Sensitive, + + /// + /// + /// The comparison is intended to be case-insensitive. The default (portable) emission normalizes both + /// sides with ToUpper(); registered expression factories may emit provider-native alternatives + /// (e.g. ILIKE on PostgreSQL). + /// + /// + Insensitive, +} diff --git a/src/RoyalCode.SmartSearch.Abstractions/DisjuctionAttribute.cs b/src/RoyalCode.SmartSearch.Abstractions/DisjunctionAttribute.cs similarity index 74% rename from src/RoyalCode.SmartSearch.Abstractions/DisjuctionAttribute.cs rename to src/RoyalCode.SmartSearch.Abstractions/DisjunctionAttribute.cs index b77924b..dcef0d3 100644 --- a/src/RoyalCode.SmartSearch.Abstractions/DisjuctionAttribute.cs +++ b/src/RoyalCode.SmartSearch.Abstractions/DisjunctionAttribute.cs @@ -10,14 +10,14 @@ /// /// Constructor with the alias. /// -/// The disjuction alias. +/// The disjunction alias. [AttributeUsage(AttributeTargets.Property)] -public sealed class DisjuctionAttribute(string alias) : Attribute +public sealed class DisjunctionAttribute(string alias) : Attribute { /// /// - /// Disjuction alias, used to group various properties in the same disjuction. + /// Disjunction alias, used to group various properties in the same disjunction. /// /// public string Alias { get; set; } = alias ?? throw new ArgumentNullException(nameof(alias)); -} \ No newline at end of file +} diff --git a/src/RoyalCode.SmartSearch.Abstractions/IResultList.cs b/src/RoyalCode.SmartSearch.Abstractions/IResultList.cs index a937f77..417e5f8 100644 --- a/src/RoyalCode.SmartSearch.Abstractions/IResultList.cs +++ b/src/RoyalCode.SmartSearch.Abstractions/IResultList.cs @@ -50,8 +50,12 @@ public interface IResultList IReadOnlyList Sortings { get; } /// - /// Projections carried out during the research. + /// Reserved for future query-level projections computed during the search. /// + /// + /// Current built-in searches do not populate this value. It is intended for future extra values, + /// such as aggregates over the filtered query before paging is applied. + /// Dictionary? Projections { get; } } @@ -67,12 +71,15 @@ public interface IResultList : IResultList IReadOnlyList Items { get; } /// - /// Gets a value from the projection if it exists and is of the type entered, - /// or returns the default value if the value does not exist or the type is different. + /// Reserved for future access to query-level projection values. /// + /// + /// Current built-in result lists do not provide functional projection lookup. Do not rely on this + /// method until projection support is implemented. + /// /// Projection value type. /// Projection name. /// Default value. - /// The projection value, or default value. + /// The projection value, or the default value, when projection support is implemented. T GetProjection(string name, T? defaultValue = default); -} \ No newline at end of file +} diff --git a/src/RoyalCode.SmartSearch.Abstractions/ISearch.cs b/src/RoyalCode.SmartSearch.Abstractions/ISearch.cs index f5acd99..6a3dded 100644 --- a/src/RoyalCode.SmartSearch.Abstractions/ISearch.cs +++ b/src/RoyalCode.SmartSearch.Abstractions/ISearch.cs @@ -74,7 +74,7 @@ ISearch Select(Expression> selectExpres /// /// The entity or null if there are no entities that meet the criteria. /// - Task FirstDefaultAsync(CancellationToken cancellationToken = default); + Task FirstOrDefaultAsync(CancellationToken cancellationToken = default); /// /// Apply the filters and sorting and get the first entity that meets the criteria, @@ -170,4 +170,4 @@ public interface ISearch /// or throw an exception if there are no entities that meet the criteria or more than one. /// Task SingleAsync(CancellationToken cancellationToken = default); -} \ No newline at end of file +} diff --git a/src/RoyalCode.SmartSearch.Abstractions/LikeWrap.cs b/src/RoyalCode.SmartSearch.Abstractions/LikeWrap.cs new file mode 100644 index 0000000..0ca210a --- /dev/null +++ b/src/RoyalCode.SmartSearch.Abstractions/LikeWrap.cs @@ -0,0 +1,36 @@ +namespace RoyalCode.SmartSearch; + +/// +/// +/// Declares, per criterion, whether the filter value of a criterion +/// is wrapped with wildcards (%value%) before matching. +/// +/// +/// Used by to override the global default +/// (CriterionDefaults.WrapLikeValue, in the Linq package). +/// +/// +public enum LikeWrap +{ + /// + /// + /// Not declared: the global default is used. + /// + /// + Default = 0, + + /// + /// + /// The value is wrapped with wildcards (%value%). + /// + /// + Wrap, + + /// + /// + /// The value is used as the pattern as-is: without user wildcards the match is exact, + /// and leading/trailing segments act as anchors (LIKE semantics). + /// + /// + None, +} diff --git a/src/RoyalCode.SmartSearch.Abstractions/ResultList.cs b/src/RoyalCode.SmartSearch.Abstractions/ResultList.cs index 88ad04a..2ccdee0 100644 --- a/src/RoyalCode.SmartSearch.Abstractions/ResultList.cs +++ b/src/RoyalCode.SmartSearch.Abstractions/ResultList.cs @@ -34,13 +34,30 @@ public class ResultList : IResultList [JsonConverter(typeof(SortingsConverter))] public IReadOnlyList Sortings { get; init; } = null!; - /// + /// + /// Reserved for future query-level projections computed during the search. + /// + /// + /// The default search pipeline does not populate this property yet. + /// public Dictionary? Projections { get; init; } = null!; /// public IReadOnlyList Items { get; init; } = null!; - /// + /// + /// Reserved for future access to query-level projection values. + /// + /// + /// This implementation is not functional yet. + /// + /// Projection value type. + /// Projection name. + /// Default value. + /// The projection value, or the default value, when projection support is implemented. + /// + /// Always thrown until projection support is implemented. + /// public virtual T GetProjection(string name, T? defaultValue = default) { throw new NotImplementedException(); diff --git a/src/RoyalCode.SmartSearch.Core/Defaults/Search.cs b/src/RoyalCode.SmartSearch.Core/Defaults/Search.cs index e35bdb0..3fc711b 100644 --- a/src/RoyalCode.SmartSearch.Core/Defaults/Search.cs +++ b/src/RoyalCode.SmartSearch.Core/Defaults/Search.cs @@ -58,7 +58,7 @@ public Task> ToAsyncListAsync(CancellationToken token public TEntity? FirstOrDefault() => performer.Prepare(options).FirstOrDefault(); /// - public Task FirstDefaultAsync(CancellationToken cancellationToken = default) + public Task FirstOrDefaultAsync(CancellationToken cancellationToken = default) => performer.Prepare(options).FirstOrDefaultAsync(cancellationToken); /// @@ -124,4 +124,4 @@ public Task> ToAsyncListAsync(CancellationToken token = d /// public Task SingleAsync(CancellationToken cancellationToken = default) => performer.Prepare(options).Select(searchSelect).SingleAsync(cancellationToken); -} \ No newline at end of file +} diff --git a/src/RoyalCode.SmartSearch.Demo.Tests/CustomersEndpointsTests.cs b/src/RoyalCode.SmartSearch.Demo.Tests/CustomersEndpointsTests.cs new file mode 100644 index 0000000..5088782 --- /dev/null +++ b/src/RoyalCode.SmartSearch.Demo.Tests/CustomersEndpointsTests.cs @@ -0,0 +1,69 @@ +using System.Net; +using FluentAssertions; + +namespace RoyalCode.SmartSearch.Demo.Tests; + +[Collection(DemoCollection.Name)] +public sealed class CustomersEndpointsTests(DemoApplicationFactory factory) +{ + private readonly HttpClient client = factory.CreateClient(); + + [Fact] + public async Task Name_Contains_CaseInsensitive_Returns_Maria() + { + var response = await client.GetAsync("/api/customers?name=maria"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("Maria Silva"); + body.Should().Contain("\"count\":1"); + } + + [Fact] + public async Task NameOrEmail_Splits_Into_Disjunction() + { + var response = await client.GetAsync("/api/customers?nameOrEmail=mario"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("Mario Souza"); + } + + [Fact] + public async Task State_Filters_Owned_Address_By_Nested_Path() + { + var response = await client.GetAsync("/api/customers?state=NY"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("\"count\":2"); + } + + [Fact] + public async Task ComplexFilter_Over_Owned_Address_By_City() + { + var response = await client.GetAsync("/manual/customers/by-address?city=NYC"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("Maria Silva").And.Contain("Mario Souza"); + body.Should().NotContain("John Appleseed"); + } + + [Fact] + public async Task Paging_Uses_Page_And_ItemsPerPage_Keys() + { + var response = await client.GetAsync("/api/customers?page=1&itemsPerPage=2"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("\"itemsPerPage\":2"); + } + + [Fact] + public async Task No_Match_Returns_204() + { + var response = await client.GetAsync("/api/customers?name=nobody-here"); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } +} diff --git a/src/RoyalCode.SmartSearch.Demo.Tests/DemoApplicationFactory.cs b/src/RoyalCode.SmartSearch.Demo.Tests/DemoApplicationFactory.cs new file mode 100644 index 0000000..c999bac --- /dev/null +++ b/src/RoyalCode.SmartSearch.Demo.Tests/DemoApplicationFactory.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Mvc.Testing; + +namespace RoyalCode.SmartSearch.Demo.Tests; + +/// +/// Boots the demo host in-memory (its own SQLite in-memory database, seeded per factory instance) so the +/// endpoints can be driven over HTTP with . +/// +public sealed class DemoApplicationFactory : WebApplicationFactory; diff --git a/src/RoyalCode.SmartSearch.Demo.Tests/DemoCollection.cs b/src/RoyalCode.SmartSearch.Demo.Tests/DemoCollection.cs new file mode 100644 index 0000000..c8903d4 --- /dev/null +++ b/src/RoyalCode.SmartSearch.Demo.Tests/DemoCollection.cs @@ -0,0 +1,12 @@ +namespace RoyalCode.SmartSearch.Demo.Tests; + +/// +/// Shares a single across all test classes. SmartSearch registers selectors +/// and named sortings in process-global static maps, so the host (and therefore AddDemoSearches) must be +/// configured only once per process. +/// +[CollectionDefinition(Name)] +public sealed class DemoCollection : ICollectionFixture +{ + public const string Name = "demo"; +} diff --git a/src/RoyalCode.SmartSearch.Demo.Tests/OrdersEndpointsTests.cs b/src/RoyalCode.SmartSearch.Demo.Tests/OrdersEndpointsTests.cs new file mode 100644 index 0000000..6bf4f51 --- /dev/null +++ b/src/RoyalCode.SmartSearch.Demo.Tests/OrdersEndpointsTests.cs @@ -0,0 +1,128 @@ +using System.Net; +using FluentAssertions; + +namespace RoyalCode.SmartSearch.Demo.Tests; + +[Collection(DemoCollection.Name)] +public sealed class OrdersEndpointsTests(DemoApplicationFactory factory) +{ + private readonly HttpClient client = factory.CreateClient(); + + [Fact] + public async Task Registered_Selector_Computes_Total_And_CustomerName() + { + var response = await client.GetAsync("/api/orders?status=Paid&orderby=createdAt-desc"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("\"customerName\"").And.Contain("\"total\""); + body.Should().Contain("\"count\":11"); // 2 seeded Paid orders + 9 generated + } + + [Fact] + public async Task Date_Range_Filter() + { + var response = await client.GetAsync("/api/orders?createdAtFrom=2026-02-01&createdAtTo=2026-05-31"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("\"count\":3"); + } + + [Fact] + public async Task Negation_Excludes_Status() + { + var response = await client.GetAsync("/api/orders?notStatus=Cancelled"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("\"count\":29"); // 30 orders minus the single Cancelled one + body.Should().NotContain("ORD-1004"); // the only Cancelled order + } + + [Fact] + public async Task In_Operator_Over_Statuses() + { + var response = await client.GetAsync("/manual/orders/by-status?statuses=Paid&statuses=Shipped"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("ORD-1001").And.Contain("ORD-1003").And.Contain("ORD-1005"); + body.Should().NotContain("ORD-1002"); // Pending + body.Should().NotContain("ORD-1004"); // Cancelled + } + + [Fact] + public async Task Exists_True_And_False() + { + (await client.GetStringAsync("/manual/orders/exists?number=1002")).Should().Contain("\"exists\":true"); + (await client.GetStringAsync("/manual/orders/exists?number=9999")).Should().Contain("\"exists\":false"); + } + + [Fact] + public async Task Single_By_Number_Loads_Navigations_Via_Hints() + { + var response = await client.GetAsync("/manual/orders/by-number/ORD-1001"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var body = await response.Content.ReadAsStringAsync(); + // Hints WithCustomer + WithItems load the navigations... + body.Should().Contain("\"customer\"").And.Contain("Maria Silva"); + body.Should().Contain("\"items\""); + // ...but the un-hinted Product navigation stays null. + body.Should().Contain("\"product\":null"); + } + + [Fact] + public async Task FirstOrDefault_Unknown_Id_Returns_SmartProblems_404() + { + var response = await client.GetAsync("/manual/orders/999"); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + + // The manual endpoint returns a SmartProblems NotFound converted to RFC-9457 ProblemDetails. + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("\"status\":404").And.Contain("was not found"); + } + + [Fact] + public async Task Single_Unknown_Number_Returns_SmartProblems_404() + { + var response = await client.GetAsync("/manual/orders/by-number/NOPE"); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("\"status\":404").And.Contain("NOPE"); + } + + [Fact] + public async Task Invalid_OrderBy_Returns_400_ProblemDetails() + { + var response = await client.GetAsync("/api/orders?orderby=bogusField"); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("bogusField").And.Contain("Order"); + } + + [Fact] + public async Task Manual_Invalid_OrderBy_Returns_SmartProblems_400() + { + var response = await client.GetAsync("/manual/orders?orderby=bogusField"); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + // The manual endpoint returns Problems.InvalidParameter as ProblemDetails (400). + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("\"status\":400").And.Contain("bogusField"); + } + + [Fact] + public async Task Manual_And_Mapped_Same_Query_Return_Identical_Results() + { + const string query = "?status=Paid&orderby=createdAt-desc"; + + var mapped = await client.GetStringAsync("/api/orders" + query); + var manual = await client.GetStringAsync("/manual/orders" + query); + + manual.Should().Be(mapped); + } +} diff --git a/src/RoyalCode.SmartSearch.Demo.Tests/ProductsEndpointsTests.cs b/src/RoyalCode.SmartSearch.Demo.Tests/ProductsEndpointsTests.cs new file mode 100644 index 0000000..491ec8f --- /dev/null +++ b/src/RoyalCode.SmartSearch.Demo.Tests/ProductsEndpointsTests.cs @@ -0,0 +1,56 @@ +using System.Net; +using FluentAssertions; + +namespace RoyalCode.SmartSearch.Demo.Tests; + +[Collection(DemoCollection.Name)] +public sealed class ProductsEndpointsTests(DemoApplicationFactory factory) +{ + private readonly HttpClient client = factory.CreateClient(); + + [Fact] + public async Task Equality_And_Numeric_Range() + { + var response = await client.GetAsync("/api/products?active=true&priceMin=50&priceMax=200"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("Webcam"); + body.Should().NotContain("Monitor"); // 899.00 is out of range + body.Should().NotContain("Headset"); // inactive + } + + [Fact] + public async Task Anchored_Like_With_Wrap_None() + { + // 'ABC%' is used as-is (no %value% wrapping): matches SKUs that start with ABC. + var response = await client.GetAsync("/api/products?sku=ABC%25"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("ABC-001").And.Contain("ABC-002").And.Contain("ABC-003"); + body.Should().NotContain("XYZ-100"); + } + + [Fact] + public async Task Disjunction_Matches_Name_Or_Sku() + { + var response = await client.GetAsync("/api/products?textInName=mo&textInSku=xyz"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var body = await response.Content.ReadAsStringAsync(); + // Mouse & Monitor by name ("mo"); Headset by sku (XYZ-200). + body.Should().Contain("Mouse").And.Contain("Monitor").And.Contain("Headset"); + body.Should().NotContain("Keyboard"); + } + + [Fact] + public async Task First_Returns_Single_Entity() + { + var response = await client.GetAsync("/api/products/first?active=true"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var body = await response.Content.ReadAsStringAsync(); + body.Should().StartWith("{").And.Contain("\"sku\""); + } +} diff --git a/src/RoyalCode.SmartSearch.Demo.Tests/RoyalCode.SmartSearch.Demo.Tests.csproj b/src/RoyalCode.SmartSearch.Demo.Tests/RoyalCode.SmartSearch.Demo.Tests.csproj new file mode 100644 index 0000000..de30483 --- /dev/null +++ b/src/RoyalCode.SmartSearch.Demo.Tests/RoyalCode.SmartSearch.Demo.Tests.csproj @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/RoyalCode.SmartSearch.Demo.Tests/Usings.cs b/src/RoyalCode.SmartSearch.Demo.Tests/Usings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/src/RoyalCode.SmartSearch.Demo.Tests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/src/RoyalCode.SmartSearch.Demo/Data/AppDbContext.cs b/src/RoyalCode.SmartSearch.Demo/Data/AppDbContext.cs new file mode 100644 index 0000000..1569a2c --- /dev/null +++ b/src/RoyalCode.SmartSearch.Demo/Data/AppDbContext.cs @@ -0,0 +1,76 @@ +using Microsoft.EntityFrameworkCore; +using RoyalCode.SmartSearch.Demo.Domain; + +namespace RoyalCode.SmartSearch.Demo.Data; + +/// +/// SQLite for the demo. Uses a single in-memory connection kept open for the +/// lifetime of the host, so the seeded database survives across requests within one run (DF8). +/// +public sealed class AppDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Customers => Set(); + public DbSet Products => Set(); + public DbSet Orders => Set(); + public DbSet OrderItems => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(b => + { + b.HasKey(c => c.Id); + b.Property(c => c.Name).IsRequired(); + b.Property(c => c.Email).IsRequired(); + + // Address as an owned type (same table) -> demonstrates [ComplexFilter] over an owned type. + b.OwnsOne(c => c.MainAddress, address => + { + address.Property(a => a.Street).HasColumnName("Street"); + address.Property(a => a.City).HasColumnName("City"); + address.Property(a => a.State).HasColumnName("State"); + address.Property(a => a.PostalCode).HasColumnName("PostalCode"); + }); + }); + + modelBuilder.Entity(b => + { + b.HasKey(p => p.Id); + b.Property(p => p.Sku).IsRequired(); + b.Property(p => p.Name).IsRequired(); + b.Property(p => p.Price).IsRequired(); + b.Property(p => p.Active).IsRequired(); + }); + + modelBuilder.Entity(b => + { + b.HasKey(o => o.Id); + b.Property(o => o.Number).IsRequired(); + b.Property(o => o.CreatedAt).IsRequired(); + b.Property(o => o.Status).IsRequired(); + + b.HasOne(o => o.Customer) + .WithMany() + .HasForeignKey(o => o.CustomerId) + .OnDelete(DeleteBehavior.Restrict); + + b.HasMany(o => o.Items) + .WithOne() + .HasForeignKey(i => i.OrderId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(b => + { + b.HasKey(i => i.Id); + b.Property(i => i.Quantity).IsRequired(); + b.Property(i => i.UnitPrice).IsRequired(); + + b.HasOne(i => i.Product) + .WithMany() + .HasForeignKey(i => i.ProductId) + .OnDelete(DeleteBehavior.Restrict); + }); + + base.OnModelCreating(modelBuilder); + } +} diff --git a/src/RoyalCode.SmartSearch.Demo/Data/DemoSeeder.cs b/src/RoyalCode.SmartSearch.Demo/Data/DemoSeeder.cs new file mode 100644 index 0000000..8d548ba --- /dev/null +++ b/src/RoyalCode.SmartSearch.Demo/Data/DemoSeeder.cs @@ -0,0 +1,107 @@ +using RoyalCode.SmartSearch.Demo.Domain; + +namespace RoyalCode.SmartSearch.Demo.Data; + +/// +/// Deterministic seed for the demo. The first records (customers 1-5, the 5 products, orders 1-5) are the +/// "named" ones the documentation and tests refer to; the remaining rows are generated deterministically to +/// provide enough volume to paginate. Generated rows deliberately avoid the values the documented filters key on +/// (no "maria"/"mario" names, no NYC/NY addresses, dates after the documented range, never Cancelled). +/// +public static class DemoSeeder +{ + public static void Seed(AppDbContext db) + { + if (db.Customers.Any()) + return; + + db.Customers.AddRange(BuildCustomers()); + db.Products.AddRange(BuildProducts()); + db.SaveChanges(); + + db.Orders.AddRange(BuildOrders()); + db.SaveChanges(); + } + + private static List BuildCustomers() + { + var customers = new List + { + new() { Id = 1, Name = "Maria Silva", Email = "maria@shop.com", MainAddress = new Address { Street = "1st Ave", City = "NYC", State = "NY", PostalCode = "10001" } }, + new() { Id = 2, Name = "Mario Souza", Email = "mario@shop.com", MainAddress = new Address { Street = "2nd St", City = "NYC", State = "NY", PostalCode = "10002" } }, + new() { Id = 3, Name = "John Appleseed", Email = "john@corp.com", MainAddress = new Address { Street = "3rd Blvd", City = "LA", State = "CA", PostalCode = "90001" } }, + new() { Id = 4, Name = "Ana Pereira", Email = "ana@corp.com", MainAddress = new Address { Street = "4th Rd", City = "SF", State = "CA", PostalCode = "94101" } }, + new() { Id = 5, Name = "Bruno Costa", Email = "bruno@shop.com", MainAddress = new Address { Street = "5th Ln", City = "Austin", State = "TX", PostalCode = "73301" } }, + }; + + // 20 more customers, none in NYC/NY and none matching "maria"/"mario". + string[] first = ["Carla", "Diego", "Elena", "Felipe", "Gabriela", "Hugo", "Isabela", "Rafael", "Julia", "Lucas", + "Marina", "Nelson", "Olivia", "Paulo", "Renata", "Sergio", "Tatiana", "Vitor", "Wanda", "Xavier"]; + string[] last = ["Dias", "Melo", "Rocha", "Nunes", "Barros", "Campos", "Freitas", "Gomes", "Lima", "Moraes"]; + (string City, string State)[] places = [("Rio", "RJ"), ("Miami", "FL"), ("Denver", "CO"), ("Boston", "MA"), ("Seattle", "WA")]; + + for (var j = 0; j < 20; j++) + { + var id = 6 + j; + var (city, state) = places[j % places.Length]; + customers.Add(new Customer + { + Id = id, + Name = $"{first[j]} {last[j % last.Length]}", + Email = $"{first[j].ToLowerInvariant()}.{last[j % last.Length].ToLowerInvariant()}@mail.com", + MainAddress = new Address + { + Street = $"{id} Market St", + City = city, + State = state, + PostalCode = $"{20000 + id}", + }, + }); + } + + return customers; + } + + private static List BuildProducts() => + [ + new() { Id = 1, Sku = "ABC-001", Name = "Keyboard", Price = 49.90m, Active = true }, + new() { Id = 2, Sku = "ABC-002", Name = "Mouse", Price = 19.90m, Active = true }, + new() { Id = 3, Sku = "XYZ-100", Name = "Monitor", Price = 899.00m, Active = true }, + new() { Id = 4, Sku = "XYZ-200", Name = "Headset", Price = 129.00m, Active = false }, + new() { Id = 5, Sku = "ABC-003", Name = "Webcam", Price = 79.00m, Active = true }, + ]; + + private static List BuildOrders() + { + var orders = new List + { + new() { Id = 1, Number = "ORD-1001", CreatedAt = new DateTime(2026, 1, 15), Status = OrderStatus.Paid, CustomerId = 1, Items = [ new() { Id = 1, ProductId = 1, Quantity = 1, UnitPrice = 49.90m }, new() { Id = 2, ProductId = 2, Quantity = 2, UnitPrice = 19.90m } ] }, + new() { Id = 2, Number = "ORD-1002", CreatedAt = new DateTime(2026, 2, 10), Status = OrderStatus.Pending, CustomerId = 2, Items = [ new() { Id = 3, ProductId = 3, Quantity = 1, UnitPrice = 899.00m } ] }, + new() { Id = 3, Number = "ORD-1003", CreatedAt = new DateTime(2026, 3, 5), Status = OrderStatus.Shipped, CustomerId = 1, Items = [ new() { Id = 4, ProductId = 5, Quantity = 3, UnitPrice = 79.00m } ] }, + new() { Id = 4, Number = "ORD-1004", CreatedAt = new DateTime(2026, 4, 20), Status = OrderStatus.Cancelled, CustomerId = 3, Items = [ new() { Id = 5, ProductId = 4, Quantity = 1, UnitPrice = 129.00m } ] }, + new() { Id = 5, Number = "ORD-1005", CreatedAt = new DateTime(2026, 6, 1), Status = OrderStatus.Paid, CustomerId = 4, Items = [ new() { Id = 6, ProductId = 3, Quantity = 2, UnitPrice = 899.00m }, new() { Id = 7, ProductId = 1, Quantity = 1, UnitPrice = 49.90m } ] }, + }; + + // 25 more orders: dates after the documented Feb-May range, never Cancelled, assigned to the generated customers. + OrderStatus[] cycle = [OrderStatus.Paid, OrderStatus.Pending, OrderStatus.Shipped]; + decimal[] prices = [49.90m, 19.90m, 899.00m, 129.00m, 79.00m]; + var itemId = 8; + + for (var j = 0; j < 25; j++) + { + var id = 6 + j; + var productId = (j % 5) + 1; + orders.Add(new Order + { + Id = id, + Number = $"ORD-{1000 + id}", + CreatedAt = new DateTime(2026, 7, 1).AddDays(j * 3), + Status = cycle[j % cycle.Length], + CustomerId = 6 + (j % 20), + Items = [new OrderItem { Id = itemId++, ProductId = productId, Quantity = (j % 3) + 1, UnitPrice = prices[productId - 1] }], + }); + } + + return orders; + } +} diff --git a/src/RoyalCode.SmartSearch.Demo/Domain/Address.cs b/src/RoyalCode.SmartSearch.Demo/Domain/Address.cs new file mode 100644 index 0000000..0702d1a --- /dev/null +++ b/src/RoyalCode.SmartSearch.Demo/Domain/Address.cs @@ -0,0 +1,24 @@ +using RoyalCode.SmartSearch; + +namespace RoyalCode.SmartSearch.Demo.Domain; + +/// +/// +/// Postal address of a . +/// +/// +/// Mapped as an owned type (OwnsOne) on the same table as the customer, +/// and annotated with so it can be targeted by complex filters. +/// +/// +[ComplexFilter] +public sealed class Address +{ + public string Street { get; set; } = null!; + + public string City { get; set; } = null!; + + public string State { get; set; } = null!; + + public string PostalCode { get; set; } = null!; +} diff --git a/src/RoyalCode.SmartSearch.Demo/Domain/Customer.cs b/src/RoyalCode.SmartSearch.Demo/Domain/Customer.cs new file mode 100644 index 0000000..dba6975 --- /dev/null +++ b/src/RoyalCode.SmartSearch.Demo/Domain/Customer.cs @@ -0,0 +1,16 @@ +namespace RoyalCode.SmartSearch.Demo.Domain; + +/// +/// A customer that places orders. +/// +public sealed class Customer +{ + public int Id { get; set; } + + public string Name { get; set; } = null!; + + public string Email { get; set; } = null!; + + /// Owned/complex address (see ). + public Address? MainAddress { get; set; } +} diff --git a/src/RoyalCode.SmartSearch.Demo/Domain/Order.cs b/src/RoyalCode.SmartSearch.Demo/Domain/Order.cs new file mode 100644 index 0000000..0b3bee4 --- /dev/null +++ b/src/RoyalCode.SmartSearch.Demo/Domain/Order.cs @@ -0,0 +1,21 @@ +namespace RoyalCode.SmartSearch.Demo.Domain; + +/// +/// A sales order placed by a . +/// +public sealed class Order +{ + public int Id { get; set; } + + public string Number { get; set; } = null!; + + public DateTime CreatedAt { get; set; } + + public OrderStatus Status { get; set; } + + public int CustomerId { get; set; } + + public Customer Customer { get; set; } = null!; + + public List Items { get; set; } = []; +} diff --git a/src/RoyalCode.SmartSearch.Demo/Domain/OrderItem.cs b/src/RoyalCode.SmartSearch.Demo/Domain/OrderItem.cs new file mode 100644 index 0000000..f1c5f7c --- /dev/null +++ b/src/RoyalCode.SmartSearch.Demo/Domain/OrderItem.cs @@ -0,0 +1,19 @@ +namespace RoyalCode.SmartSearch.Demo.Domain; + +/// +/// A single line of an . +/// +public sealed class OrderItem +{ + public int Id { get; set; } + + public int OrderId { get; set; } + + public int ProductId { get; set; } + + public Product Product { get; set; } = null!; + + public int Quantity { get; set; } + + public decimal UnitPrice { get; set; } +} diff --git a/src/RoyalCode.SmartSearch.Demo/Domain/OrderStatus.cs b/src/RoyalCode.SmartSearch.Demo/Domain/OrderStatus.cs new file mode 100644 index 0000000..d5a20e5 --- /dev/null +++ b/src/RoyalCode.SmartSearch.Demo/Domain/OrderStatus.cs @@ -0,0 +1,12 @@ +namespace RoyalCode.SmartSearch.Demo.Domain; + +/// +/// Status of an . +/// +public enum OrderStatus +{ + Pending, + Paid, + Shipped, + Cancelled, +} diff --git a/src/RoyalCode.SmartSearch.Demo/Domain/Product.cs b/src/RoyalCode.SmartSearch.Demo/Domain/Product.cs new file mode 100644 index 0000000..690005b --- /dev/null +++ b/src/RoyalCode.SmartSearch.Demo/Domain/Product.cs @@ -0,0 +1,17 @@ +namespace RoyalCode.SmartSearch.Demo.Domain; + +/// +/// A product that can be sold in an order. +/// +public sealed class Product +{ + public int Id { get; set; } + + public string Sku { get; set; } = null!; + + public string Name { get; set; } = null!; + + public decimal Price { get; set; } + + public bool Active { get; set; } +} diff --git a/src/RoyalCode.SmartSearch.Demo/Dtos/CustomerDto.cs b/src/RoyalCode.SmartSearch.Demo/Dtos/CustomerDto.cs new file mode 100644 index 0000000..d9ff4ef --- /dev/null +++ b/src/RoyalCode.SmartSearch.Demo/Dtos/CustomerDto.cs @@ -0,0 +1,12 @@ +namespace RoyalCode.SmartSearch.Demo.Dtos; + +/// +/// Projection of a customer. City is flattened from the owned MainAddress. +/// +public sealed class CustomerDto +{ + public int Id { get; set; } + public string Name { get; set; } = null!; + public string Email { get; set; } = null!; + public string? City { get; set; } +} diff --git a/src/RoyalCode.SmartSearch.Demo/Dtos/OrderSummaryDto.cs b/src/RoyalCode.SmartSearch.Demo/Dtos/OrderSummaryDto.cs new file mode 100644 index 0000000..72e453f --- /dev/null +++ b/src/RoyalCode.SmartSearch.Demo/Dtos/OrderSummaryDto.cs @@ -0,0 +1,17 @@ +using RoyalCode.SmartSearch.Demo.Domain; + +namespace RoyalCode.SmartSearch.Demo.Dtos; + +/// +/// Summary projection of an order. CustomerName comes from the customer navigation and Total is +/// computed from the items, so it is projected by a registered selector (see the search configuration). +/// +public sealed class OrderSummaryDto +{ + public int Id { get; set; } + public string Number { get; set; } = null!; + public DateTime CreatedAt { get; set; } + public OrderStatus Status { get; set; } + public string CustomerName { get; set; } = null!; + public decimal Total { get; set; } +} diff --git a/src/RoyalCode.SmartSearch.Demo/Dtos/ProductDto.cs b/src/RoyalCode.SmartSearch.Demo/Dtos/ProductDto.cs new file mode 100644 index 0000000..23981e4 --- /dev/null +++ b/src/RoyalCode.SmartSearch.Demo/Dtos/ProductDto.cs @@ -0,0 +1,14 @@ +namespace RoyalCode.SmartSearch.Demo.Dtos; + +/// +/// Projection of a product. Every member name matches Product, so it is projected by convention +/// (no registered selector) via Select<ProductDto>(). +/// +public sealed class ProductDto +{ + public int Id { get; set; } + public string Sku { get; set; } = null!; + public string Name { get; set; } = null!; + public decimal Price { get; set; } + public bool Active { get; set; } +} diff --git a/src/RoyalCode.SmartSearch.Demo/Endpoints/ManualSearchEndpoints.cs b/src/RoyalCode.SmartSearch.Demo/Endpoints/ManualSearchEndpoints.cs new file mode 100644 index 0000000..ea22c67 --- /dev/null +++ b/src/RoyalCode.SmartSearch.Demo/Endpoints/ManualSearchEndpoints.cs @@ -0,0 +1,187 @@ +using Microsoft.AspNetCore.Mvc; +using RoyalCode.SmartProblems; +using RoyalCode.SmartProblems.HttpResults; +using RoyalCode.SmartSearch.AspNetCore.HttpResults; +using RoyalCode.SmartSearch.Demo.Domain; +using RoyalCode.SmartSearch.Demo.Dtos; +using RoyalCode.SmartSearch.Demo.Filters; +using RoyalCode.SmartSearch.Demo.Search; +using RoyalCode.SmartSearch.Exceptions; + +namespace RoyalCode.SmartSearch.Demo.Endpoints; + +/// +/// Endpoints that use directly, without the AspNetCore helpers. They show the +/// full surface: Collect/ToList, Select (explicit and by convention), Exists, Single, FirstOrDefault, In, +/// Skip/Take + UseCount, [ComplexFilter] and per-query hints. +/// +/// Error handling uses SmartProblems: recoverable cases return through the +/// OkMatch<T> result type (converted to RFC-9457 ProblemDetails), and the group's +/// WithExceptionFilter turns any unexpected exception into a 500 ProblemDetails. +/// +/// +public static class ManualSearchEndpoints +{ + public static IEndpointRouteBuilder MapManualSearchEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/manual") + .WithTags("Manual (ICriteria)") + .WithExceptionFilter(); + + // Customers filtered by a flat filter, projected with an explicit Select expression. + // Invalid order by -> SmartProblems InvalidParameter (400) via the standardized MatchList result. + group.MapGet("/customers", async Task> ( + [AsParameters] CustomerFilter filter, + [FromQuery] Sorting[]? orderby, + [FromServices] ICriteria criteria, + CancellationToken ct) => + { + try + { + var result = await criteria + .OrderBy(orderby) + .FilterBy(filter) + .Select(c => new CustomerDto + { + Id = c.Id, + Name = c.Name, + Email = c.Email, + City = c.MainAddress != null ? c.MainAddress.City : null, + }) + .ToListAsync(ct); + + if (result.Count == 0) + return TypedResults.NoContent(); + + return TypedResults.Ok(result.Items); + } + catch (OrderByException ex) + { + return Problems.InvalidParameter(ex.Message, "orderby"); + } + }); + + // Customers filtered by a structured [ComplexFilter] over the owned address, projected via the + // registered selector (Select()). + group.MapGet("/customers/by-address", async ( + string? city, + string? state, + [FromServices] ICriteria criteria, + CancellationToken ct) => + { + var filter = new CustomerAddressFilter { Address = new AddressFilter { City = city, State = state } }; + var result = await criteria.FilterBy(filter).Select().ToListAsync(ct); + return result.Count == 0 ? Results.NoContent() : Results.Ok(result.Items); + }); + + // The same query as the mapped GET /api/orders (kitchen-sink filter + paged OrderSummaryDto), built by hand. + // Returns the same standardized MatchSearch result; invalid order by -> SmartProblems InvalidParameter (400). + group.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); + + if (result.Count == 0) + return TypedResults.NoContent(); + + return TypedResults.Ok(result); + } + catch (OrderByException ex) + { + return Problems.InvalidParameter(ex.Message, "orderby"); + } + }); + + // In operator: statuses come as an array query param and are assigned to an IEnumerable<> filter property. + group.MapGet("/orders/by-status", async ( + [FromQuery] OrderStatus[]? statuses, + [FromServices] ICriteria criteria, + CancellationToken ct) => + { + var filter = new OrderStatusesFilter { Statuses = statuses }; + var result = await criteria.FilterBy(filter).Select().ToListAsync(ct); + return result.Count == 0 ? Results.NoContent() : Results.Ok(result.Items); + }); + + // Offset paging (Skip/Take) plus UseCount(false): a "next page" that does not compute the total. + group.MapGet("/orders/page", async ( + int skip, + int take, + bool? count, + [FromServices] ICriteria criteria, + CancellationToken ct) => + { + var result = await criteria + .FilterBy(new OrderFilter()) + .OrderBy(new Sorting { OrderBy = "createdAt" }) + .SkipTake(skip, take) + .UseCount(count ?? false) + .Select() + .ToListAsync(ct); + + return Results.Ok(result); + }); + + // Exists: cheap existence check (hints and projections do not apply). + group.MapGet("/orders/exists", async ( + [AsParameters] OrderFilter filter, + [FromServices] ICriteria criteria, + CancellationToken ct) => + { + var exists = await criteria.FilterBy(filter).ExistsAsync(ct); + return Results.Ok(new { exists }); + }); + + // Single: exactly-one contract by unique number. No element -> SmartProblems NotFound (404). + group.MapGet("/orders/by-number/{number}", async Task> ( + string number, + [FromServices] ICriteria criteria, + CancellationToken ct) => + { + try + { + var order = await criteria + .UseHints(OrderHints.WithCustomer, OrderHints.WithItems) + .FilterBy(new OrderByNumberFilter { Number = number }) + .SingleAsync(ct); + return order; + } + catch (InvalidOperationException) + { + // Single throws when there is no element (or more than one). + return Problems.NotFound($"Order '{number}' was not found.", "number"); + } + }); + + // FirstOrDefault by id, with per-query hints loading the Customer and Items navigations. + // Not found -> SmartProblems NotFound (404). + group.MapGet("/orders/{id:int}", async Task> ( + int id, + [FromServices] ICriteria criteria, + CancellationToken ct) => + { + var order = await criteria + .UseHints(OrderHints.WithCustomer, OrderHints.WithItems) + .FilterBy(new OrderByIdFilter { Id = id }) + .FirstOrDefaultAsync(ct); + + if (order is null) + return Problems.NotFound($"Order '{id}' was not found.", "id"); + + return order; + }); + + return app; + } +} diff --git a/src/RoyalCode.SmartSearch.Demo/Endpoints/MappedSearchEndpoints.cs b/src/RoyalCode.SmartSearch.Demo/Endpoints/MappedSearchEndpoints.cs new file mode 100644 index 0000000..191d731 --- /dev/null +++ b/src/RoyalCode.SmartSearch.Demo/Endpoints/MappedSearchEndpoints.cs @@ -0,0 +1,35 @@ +using RoyalCode.SmartSearch.Demo.Domain; +using RoyalCode.SmartSearch.Demo.Dtos; +using RoyalCode.SmartSearch.Demo.Filters; + +namespace RoyalCode.SmartSearch.Demo.Endpoints; + +/// +/// Endpoints built with the AspNetCore helpers. Each helper wires filtering, sorting, paging and the standardized +/// HTTP results (200/204/400/500) for you. The filter type is bound from the query string via [AsParameters]. +/// +public static class MappedSearchEndpoints +{ + public static IEndpointRouteBuilder MapMappedSearchEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("api") + .WithExceptionFilter(); + + // Paged DTO search. Same filter, DTO and result as the manual GET /manual/orders. + group.MapSearch("/orders").WithTags("Mapped (helpers)"); + + // Paged DTO search for customers (uses the registered CustomerDto selector). + group.MapSearch("/customers").WithTags("Mapped (helpers)"); + + // Simple (non-paged) list of DTOs. ProductDto is projected by convention. + group.MapList("/products").WithTags("Mapped (helpers)"); + + // First entity matching the filter. + group.MapFirst("/products/first").WithTags("Mapped (helpers)"); + + // First DTO matching the filter (uses the registered CustomerDto selector). + group.MapSelectFirst("/customers/first").WithTags("Mapped (helpers)"); + + return group; + } +} diff --git a/src/RoyalCode.SmartSearch.Demo/Filters/AddressFilter.cs b/src/RoyalCode.SmartSearch.Demo/Filters/AddressFilter.cs new file mode 100644 index 0000000..879d3c7 --- /dev/null +++ b/src/RoyalCode.SmartSearch.Demo/Filters/AddressFilter.cs @@ -0,0 +1,27 @@ +using RoyalCode.SmartSearch; + +namespace RoyalCode.SmartSearch.Demo.Filters; + +/// +/// A structured, complex filter over the owned Address type. Marked with , +/// its inner properties map by name to the target address members (e.g. City -> MainAddress.City). +/// Used by . +/// +[ComplexFilter] +public sealed class AddressFilter +{ + public string? City { get; set; } + + public string? State { get; set; } +} + +/// +/// Customer filter that carries a nested targeting the owned MainAddress. +/// Because the value is a structured object, it is built by hand in the manual endpoint rather than bound +/// from a flat query string. +/// +public sealed class CustomerAddressFilter +{ + [Criterion("MainAddress")] + public AddressFilter? Address { get; set; } +} diff --git a/src/RoyalCode.SmartSearch.Demo/Filters/CustomerFilter.cs b/src/RoyalCode.SmartSearch.Demo/Filters/CustomerFilter.cs new file mode 100644 index 0000000..6fafe7d --- /dev/null +++ b/src/RoyalCode.SmartSearch.Demo/Filters/CustomerFilter.cs @@ -0,0 +1,27 @@ +using RoyalCode.SmartSearch; + +namespace RoyalCode.SmartSearch.Demo.Filters; + +/// +/// Flat filter for customers. Every property binds cleanly from the query string, so it works both with +/// the AspNetCore helpers (MapSearch/MapList/MapSelectFirst) and with manual ICriteria. +/// +public sealed class CustomerFilter +{ + /// Case-insensitive "contains" over Customer.Name. Query: ?name=maria. + [Criterion(CriterionOperator.Contains, Case = CriterionCase.Insensitive)] + public string? Name { get; set; } + + /// + /// OR inferred from the property name: the token "Or" splits into a disjunction over + /// Name and Email. Query: ?nameOrEmail=mario. + /// + public string? NameOrEmail { get; set; } + + /// + /// Filters the owned Address by a nested target path. Query: ?state=NY. + /// (An alternative, structured way to filter the address is shown with .) + /// + [Criterion("MainAddress.State")] + public string? State { get; set; } +} diff --git a/src/RoyalCode.SmartSearch.Demo/Filters/OrderFilter.cs b/src/RoyalCode.SmartSearch.Demo/Filters/OrderFilter.cs new file mode 100644 index 0000000..ca8e52f --- /dev/null +++ b/src/RoyalCode.SmartSearch.Demo/Filters/OrderFilter.cs @@ -0,0 +1,47 @@ +using RoyalCode.SmartSearch; +using RoyalCode.SmartSearch.Demo.Domain; + +namespace RoyalCode.SmartSearch.Demo.Filters; + +/// +/// Kitchen-sink filter for orders: the canonical, heavily commented copy-paste reference. Every property is a +/// scalar so it binds cleanly from the query string and can be reused by both the manual and the mapped endpoints. +/// +public sealed class OrderFilter +{ + /// "Contains" over Order.Number. Query: ?number=1001. + [Criterion(CriterionOperator.Contains)] + public string? Number { get; set; } + + /// + /// Filters across the Customer navigation using a nested target path. Query: ?customerName=maria. + /// + [Criterion("Customer.Name", CriterionOperator.Contains, Case = CriterionCase.Insensitive)] + public string? CustomerName { get; set; } + + /// Equality over Status. Query: ?status=Paid. + public OrderStatus? Status { get; set; } + + /// Negation: excludes orders whose Status equals the value. Query: ?notStatus=Cancelled. + [Criterion("Status", Negation = true)] + public OrderStatus? NotStatus { get; set; } + + /// Date range (lower bound) over CreatedAt. Query: ?createdAtFrom=2026-02-01. + [Criterion("CreatedAt", CriterionOperator.GreaterThanOrEqual)] + public DateTime? CreatedAtFrom { get; set; } + + /// Date range (upper bound) over CreatedAt. Query: ?createdAtTo=2026-05-31. + [Criterion("CreatedAt", CriterionOperator.LessThanOrEqual)] + public DateTime? CreatedAtTo { get; set; } + + /// + /// + /// Escape hatch for the automatic OR-from-name behavior. This property name contains the token "Or", + /// which would otherwise be split into a disjunction over members "Number" and "Code". We intend a single + /// criterion over Order.Number, so we set and the + /// explicit target path. Query: ?numberOrCode=1001. See the README for the full explanation. + /// + /// + [Criterion("Number", DisableOrFromName = true)] + public string? NumberOrCode { get; set; } +} diff --git a/src/RoyalCode.SmartSearch.Demo/Filters/OrderLookupFilters.cs b/src/RoyalCode.SmartSearch.Demo/Filters/OrderLookupFilters.cs new file mode 100644 index 0000000..c7f031b --- /dev/null +++ b/src/RoyalCode.SmartSearch.Demo/Filters/OrderLookupFilters.cs @@ -0,0 +1,17 @@ +using RoyalCode.SmartSearch; + +namespace RoyalCode.SmartSearch.Demo.Filters; + +/// Exact-match lookup by order id (used with FirstOrDefault + hints). +public sealed class OrderByIdFilter +{ + [Criterion(CriterionOperator.Equal)] + public int? Id { get; set; } +} + +/// Exact-match lookup by order number (used with Single). +public sealed class OrderByNumberFilter +{ + [Criterion(CriterionOperator.Equal)] + public string? Number { get; set; } +} diff --git a/src/RoyalCode.SmartSearch.Demo/Filters/OrderStatusesFilter.cs b/src/RoyalCode.SmartSearch.Demo/Filters/OrderStatusesFilter.cs new file mode 100644 index 0000000..4a52976 --- /dev/null +++ b/src/RoyalCode.SmartSearch.Demo/Filters/OrderStatusesFilter.cs @@ -0,0 +1,16 @@ +using RoyalCode.SmartSearch; +using RoyalCode.SmartSearch.Demo.Domain; + +namespace RoyalCode.SmartSearch.Demo.Filters; + +/// +/// Demonstrates the operator. The filter property must be declared exactly as +/// (not List<T> nor an array), which is why the manual endpoint accepts an +/// array query parameter and assigns it to this property before filtering. +/// +public sealed class OrderStatusesFilter +{ + /// Order.Status IN (...). Query (via the manual endpoint): ?statuses=Paid&statuses=Shipped. + [Criterion("Status")] + public IEnumerable? Statuses { get; set; } +} diff --git a/src/RoyalCode.SmartSearch.Demo/Filters/ProductFilter.cs b/src/RoyalCode.SmartSearch.Demo/Filters/ProductFilter.cs new file mode 100644 index 0000000..1fadab9 --- /dev/null +++ b/src/RoyalCode.SmartSearch.Demo/Filters/ProductFilter.cs @@ -0,0 +1,38 @@ +using RoyalCode.SmartSearch; + +namespace RoyalCode.SmartSearch.Demo.Filters; + +/// +/// Filter for products, demonstrating equality, numeric range, an anchored Like and a [Disjunction]. +/// +public sealed class ProductFilter +{ + /// Equality over Active. Query: ?active=true. + public bool? Active { get; set; } + + /// Numeric range (lower bound) over Price. Query: ?priceMin=50. + [Criterion("Price", CriterionOperator.GreaterThanOrEqual)] + public decimal? PriceMin { get; set; } + + /// Numeric range (upper bound) over Price. Query: ?priceMax=200. + [Criterion("Price", CriterionOperator.LessThanOrEqual)] + public decimal? PriceMax { get; set; } + + /// + /// Anchored Like: the value is used as the pattern as-is (no %value% wrapping), + /// so ABC% matches SKUs that start with "ABC". Query: ?sku=ABC%25. + /// + [Criterion(CriterionOperator.Like, Wrap = LikeWrap.None)] + public string? Sku { get; set; } + + /// + /// Part of the "text" disjunction: matches when Name contains the term OR Sku contains the + /// (possibly different) term. Query: ?textInName=mo&textInSku=xyz. + /// + [Disjunction("text"), Criterion("Name", CriterionOperator.Contains, Case = CriterionCase.Insensitive)] + public string? TextInName { get; set; } + + /// Part of the "text" disjunction (see ). + [Disjunction("text"), Criterion("Sku", CriterionOperator.Contains, Case = CriterionCase.Insensitive)] + public string? TextInSku { get; set; } +} diff --git a/src/RoyalCode.SmartSearch.Demo/Program.cs b/src/RoyalCode.SmartSearch.Demo/Program.cs new file mode 100644 index 0000000..2854b0c --- /dev/null +++ b/src/RoyalCode.SmartSearch.Demo/Program.cs @@ -0,0 +1,46 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using RoyalCode.SmartSearch.Demo.Data; +using RoyalCode.SmartSearch.Demo.Endpoints; +using Scalar.AspNetCore; + +var builder = WebApplication.CreateBuilder(args); + +// A single in-memory SQLite connection kept open for the host lifetime: the database is created empty per run +// and stays alive while the connection is open (DF8). +var connection = new SqliteConnection("DataSource=:memory:"); +connection.Open(); +builder.Services.AddSingleton(connection); +builder.Services.AddDbContext(options => options.UseSqlite(connection)); + +// SmartSearch: ICriteria services, selectors, named sortings, native Like operator and operation hints. +builder.Services.AddDemoSearches(); + +builder.Services.AddProblemDetails(); +builder.Services.AddOpenApi(); + +var app = builder.Build(); + +// Create the schema on the shared connection and apply the deterministic seed. +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); + DemoSeeder.Seed(db); +} + +app.MapOpenApi(); + +// Scalar API reference UI (Swagger-like) served at /scalar, backed by the OpenAPI document. +app.MapScalarApiReference(options => options.WithTitle("RoyalCode.SmartSearch.Demo")); + +app.MapManualSearchEndpoints(); +app.MapMappedSearchEndpoints(); + +// Home page: the Scalar API reference. +app.MapGet("/", () => Results.Redirect("/scalar")).ExcludeFromDescription(); + +app.Run(); + +// Exposed so the tests project can drive the app with WebApplicationFactory. +public partial class Program; diff --git a/src/RoyalCode.SmartSearch.Demo/Properties/launchSettings.json b/src/RoyalCode.SmartSearch.Demo/Properties/launchSettings.json new file mode 100644 index 0000000..d97d7bd --- /dev/null +++ b/src/RoyalCode.SmartSearch.Demo/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/RoyalCode.SmartSearch.Demo/README.md b/src/RoyalCode.SmartSearch.Demo/README.md new file mode 100644 index 0000000..0d60e5e --- /dev/null +++ b/src/RoyalCode.SmartSearch.Demo/README.md @@ -0,0 +1,134 @@ +# RoyalCode.SmartSearch.Demo + +Executable WebAPI demo for **RoyalCode.SmartSearch**. It exists for documentation and experimentation only +(it is never published as a NuGet package) and is meant to be copied from: every filter, DTO and endpoint is a +small, self-contained example of one or more SmartSearch features. + +It uses **SQLite in-memory** with a single connection kept open for the process lifetime, so the database is +created empty on every run and seeded deterministically. Nothing external is required. The seed has 25 customers, +5 products and 30 orders, so the list/search endpoints paginate (default 10 items per page). + +## Run + +```powershell +dotnet run --project .\RoyalCode.SmartSearch.Demo\RoyalCode.SmartSearch.Demo.csproj +``` + +The app listens on `http://localhost:5080`. The home page (`/`) opens the **Scalar** API reference UI at +`/scalar`, backed by the OpenAPI document at `/openapi/v1.json`. Ready-to-run calls are in +[`RoyalCode.SmartSearch.Demo.http`](./RoyalCode.SmartSearch.Demo.http). + +## Two ways to search + +The demo shows the same capability through two surfaces: + +- **Manual** (`/manual/*`, [`Endpoints/ManualSearchEndpoints.cs`](./Endpoints/ManualSearchEndpoints.cs)) — uses + `ICriteria` directly. You control filtering, sorting, paging, projection and terminals by hand. +- **Mapped** (`/api/*`, [`Endpoints/MappedSearchEndpoints.cs`](./Endpoints/MappedSearchEndpoints.cs)) — uses the + AspNetCore helpers `MapSearch` / `MapList` / `MapFirst` / `MapSelectFirst`, which wire the filter (bound from + the query string via `[AsParameters]`), sorting, paging and standardized HTTP results (200/204/400/500) for you. + +`GET /api/orders` (mapped) and `GET /manual/orders` (manual) implement the **same query** so you can compare them +side by side. + +## Coverage matrix + +Every row is a feature, where it lives, and a call that exercises it against the seed. Base URL omitted +(`http://localhost:5080`). + +| Feature | Where | Endpoint + query string | Expected | +|---|---|---|---| +| Equality | `ProductFilter.Active` | `GET /api/products?active=true` | active products only | +| Numeric range | `ProductFilter.PriceMin/PriceMax` | `GET /api/products?priceMin=50&priceMax=200` | Webcam (79.00) | +| Date range | `OrderFilter.CreatedAtFrom/CreatedAtTo` | `GET /api/orders?createdAtFrom=2026-02-01&createdAtTo=2026-05-31` | orders 2,3,4 | +| `In` (list) | `OrderStatusesFilter.Statuses` (`IEnumerable<>`) | `GET /manual/orders/by-status?statuses=Paid&statuses=Shipped` | Paid/Shipped orders (incl. 1,3,5) | +| Contains + case-insensitive | `CustomerFilter.Name` | `GET /api/customers?name=maria` | Maria Silva | +| `Like` anchored (`Wrap=None`) | `ProductFilter.Sku` | `GET /api/products?sku=ABC%25` | SKUs starting `ABC` | +| OR by name (token split) | `CustomerFilter.NameOrEmail` | `GET /api/customers?nameOrEmail=mario` | Mario Souza | +| `[Disjunction]` | `ProductFilter.TextInName/TextInSku` | `GET /api/products?textInName=mo&textInSku=xyz` | Mouse, Monitor, Headset | +| `DisableOrFromName` (trap) | `OrderFilter.NumberOrCode` | `GET /api/orders?numberOrCode=1001` | order 1 (see note) | +| `[ComplexFilter]` (owned) | `AddressFilter` via `CustomerAddressFilter` | `GET /manual/customers/by-address?city=NYC` | Maria, Mario | +| Nested target path | `CustomerFilter.State` / `OrderFilter.CustomerName` | `GET /api/orders?customerName=maria` | orders 1,3 | +| Negation | `OrderFilter.NotStatus` | `GET /api/orders?notStatus=Cancelled` | all but order 4 | +| Named sorting | `AddOrderBy("createdAt", ...)` | `GET /api/orders?orderby=createdAt-desc` | newest first | +| Paging (`UsePages`) | `SearchOptions` (mapped) | `GET /api/orders?page=1&itemsPerPage=2` | page 1, 2 items | +| Paging (`Skip`/`Take`) | manual | `GET /manual/orders/page?skip=2&take=2` | 3rd–4th orders | +| `UseCount(false)` | manual | `GET /manual/orders/page?skip=2&take=2&count=false` | `count` not computed (0) | +| Projection by convention | `ProductDto` (`Select()`) | `GET /api/products` | product DTOs | +| Projection by registered selector | `OrderSummaryDto` (`AddSelector`) | `GET /api/orders` | `customerName` + `total` | +| Projection by explicit expression | `Select(expr)` | `GET /manual/customers?name=maria` | customer DTOs | +| `Exists` | manual | `GET /manual/orders/exists?number=1002` | `{ "exists": true }` | +| `Single` | manual | `GET /manual/orders/by-number/ORD-1001` | order 1 (throws if 0/2+) | +| `FirstOrDefault` | mapped / manual | `GET /api/products/first?active=true` | first active product | +| Hint loads navigation | `UseHints(WithCustomer, WithItems)` | `GET /manual/orders/1` | `customer` + `items` populated | +| Hint ignored by projection | `MapSearch` DTO | `GET /api/orders` | hints do not apply to DTOs | +| Manual vs mapped (same query) | `OrderFilter` | `GET /api/orders` == `GET /manual/orders` | identical results | +| Invalid order by → 400 | pipeline (`OrderByException`) | `GET /api/orders?orderby=bogusField` | ProblemDetails 400 | + +## Notes + +### The three OR behaviors + +1. **OR by name** — a filter property whose name (or target path) contains the token `Or` is split into a + disjunction. `CustomerFilter.NameOrEmail` becomes `Name` OR `Email`. +2. **`[Disjunction("alias")]`** — group several filter properties (each with its own value and target) into one + OR clause. `ProductFilter.TextInName`/`TextInSku` match `Name` contains X **OR** `Sku` contains Y. +3. **`DisableOrFromName`** — the escape hatch. A property named `NumberOrCode` would be split into `Number` OR + `Code`; if `Code` is not a real member the filter would break. Setting + `[Criterion("Number", DisableOrFromName = true)]` keeps it as a single criterion over `Number`. Use it for + natural names that merely contain the substring `Or` (e.g. `ColorOrSize`, `NomeOrApelido`). + +### Complex filter over the owned Address + +`Address` is mapped as an owned type (`OwnsOne`) on the customer table and annotated with `[ComplexFilter]`. +You can filter it two ways: + +- a **nested target path** on a flat, query-friendly property: `CustomerFilter.State` → + `[Criterion("MainAddress.State")]` (`GET /api/customers?state=NY`); +- a **structured `[ComplexFilter]` object**: `AddressFilter` inside `CustomerAddressFilter` + (`GET /manual/customers/by-address?city=NYC&state=NY`), built by hand in the manual endpoint because a nested + object does not bind from a flat query string. + +### Operation hints + +Hints map to EF includes (`ConfigureOperationHints` + `AddIncludesHandler`). They apply only to +entity-materializing terminals (`Collect`/`Single`/`FirstOrDefault`) — `GET /manual/orders/1` returns the order +with its `customer` and `items` loaded (but `items[].product` is `null`, since that navigation was not hinted). +Hints do **not** apply to `Select()` projections nor to `Exists`, so the DTO endpoints never depend on them. + +### Invalid order by + +When `orderby` names an unknown property, the pipeline throws `OrderByException`, which the AspNetCore helpers turn +into an RFC-7807 `ProblemDetails` with HTTP 400: + +```json +{ + "type": "about:blank", + "title": "The input parameters are invalid", + "status": 400, + "detail": "The order by 'bogusField' is not supported for the type 'Order'.", + "propertyName": "bogusField", + "typeName": "Order", + "pointer": "#/orderby" +} +``` + +### SmartProblems in the manual endpoints + +The manual endpoints ([`Endpoints/ManualSearchEndpoints.cs`](./Endpoints/ManualSearchEndpoints.cs)) use +[SmartProblems](../.ai/references/problems/problems.md) for error handling: an invalid order by returns +`Problems.InvalidParameter(...)` (400) and a missing order returns `Problems.NotFound(...)` (404), both surfaced as +RFC-9457 ProblemDetails through the `OkMatch` / `MatchList` / `MatchSearch` result types. The endpoint +group also adds `WithExceptionFilter()`, which turns any unexpected exception into a 500 ProblemDetails. + +## Layout + +```text +Domain/ Customer, Address ([ComplexFilter], owned), Product, Order, OrderItem, OrderStatus +Data/ AppDbContext (SQLite + OwnsOne mapping), DemoSeeder (deterministic seed) +Filters/ CustomerFilter, AddressFilter (+CustomerAddressFilter), ProductFilter, OrderFilter (kitchen-sink), + OrderStatusesFilter (In), OrderLookupFilters (Equal) +Dtos/ CustomerDto, ProductDto (convention), OrderSummaryDto (registered selector) +Search/ OrderHints, SearchSetup (Add<>, AddSelector, AddOrderBy, Like operator, ConfigureOperationHints) +Endpoints/ ManualSearchEndpoints (ICriteria), MappedSearchEndpoints (helpers) +``` diff --git a/src/RoyalCode.SmartSearch.Demo/RoyalCode.SmartSearch.Demo.csproj b/src/RoyalCode.SmartSearch.Demo/RoyalCode.SmartSearch.Demo.csproj new file mode 100644 index 0000000..b457d27 --- /dev/null +++ b/src/RoyalCode.SmartSearch.Demo/RoyalCode.SmartSearch.Demo.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + enable + enable + false + + $(NoWarn);1591 + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RoyalCode.SmartSearch.Demo/RoyalCode.SmartSearch.Demo.http b/src/RoyalCode.SmartSearch.Demo/RoyalCode.SmartSearch.Demo.http new file mode 100644 index 0000000..ad85fac --- /dev/null +++ b/src/RoyalCode.SmartSearch.Demo/RoyalCode.SmartSearch.Demo.http @@ -0,0 +1,81 @@ +@host = http://localhost:5080 + +### OpenAPI document +GET {{host}}/openapi/v1.json + +############################################################ +# Mapped endpoints (AspNetCore helpers) +############################################################ + +### Customers: Contains + case-insensitive over Name +GET {{host}}/api/customers?name=maria + +### Customers: OR inferred from the property name (Name OR Email) +GET {{host}}/api/customers?nameOrEmail=mario + +### Customers: nested owned-address path (MainAddress.State) +GET {{host}}/api/customers?state=NY + +### Customers: paged (UsePages via SearchOptions) +GET {{host}}/api/customers?page=1&itemsPerPage=2 + +### Customers: first DTO +GET {{host}}/api/customers/first?state=CA + +### Products: equality + numeric range +GET {{host}}/api/products?active=true&priceMin=50&priceMax=200 + +### Products: anchored Like (Wrap=None) -> SKUs starting with ABC +GET {{host}}/api/products?sku=ABC%25 + +### Products: [Disjunction] -> Name contains X OR Sku contains Y +GET {{host}}/api/products?textInName=mo&textInSku=xyz + +### Products: first entity +GET {{host}}/api/products/first?active=true + +### Orders: paged DTO search, sorted (named sorting createdAt, desc) +GET {{host}}/api/orders?status=Paid&orderby=createdAt-desc + +### Orders: date range +GET {{host}}/api/orders?createdAtFrom=2026-02-01&createdAtTo=2026-05-31 + +### Orders: nested navigation path (Customer.Name) +GET {{host}}/api/orders?customerName=maria + +### Orders: negation (exclude Cancelled) +GET {{host}}/api/orders?notStatus=Cancelled + +### Orders: paged +GET {{host}}/api/orders?page=1&itemsPerPage=2 + +### Orders: invalid order by -> 400 ProblemDetails +GET {{host}}/api/orders?orderby=bogusField + +############################################################ +# Manual endpoints (ICriteria) +############################################################ + +### Manual customers: explicit Select expression +GET {{host}}/manual/customers?name=maria + +### Manual customers: [ComplexFilter] over owned Address +GET {{host}}/manual/customers/by-address?city=NYC&state=NY + +### Manual orders: same query as mapped GET /api/orders (compare) +GET {{host}}/manual/orders?status=Paid&orderby=createdAt-desc + +### Manual orders: In operator (Status in [Paid, Shipped]) +GET {{host}}/manual/orders/by-status?statuses=Paid&statuses=Shipped + +### Manual orders: Skip/Take + UseCount(false) +GET {{host}}/manual/orders/page?skip=2&take=2&count=false + +### Manual orders: Exists +GET {{host}}/manual/orders/exists?number=1002 + +### Manual orders: Single by unique number (+ hints) +GET {{host}}/manual/orders/by-number/ORD-1001 + +### Manual orders: FirstOrDefault by id (+ hints load Customer and Items) +GET {{host}}/manual/orders/1 diff --git a/src/RoyalCode.SmartSearch.Demo/Search/OrderHints.cs b/src/RoyalCode.SmartSearch.Demo/Search/OrderHints.cs new file mode 100644 index 0000000..c27cfb9 --- /dev/null +++ b/src/RoyalCode.SmartSearch.Demo/Search/OrderHints.cs @@ -0,0 +1,14 @@ +namespace RoyalCode.SmartSearch.Demo.Search; + +/// +/// Operation hints for Order. Each value maps (in the search configuration) to an EF include, so that +/// entity-materializing terminals (Collect/Single/FirstOrDefault) load the requested graph. +/// Hints are NOT applied to Select<TDto>() projections nor to Exists. +/// +[Flags] +public enum OrderHints +{ + None = 0, + WithCustomer = 1, + WithItems = 2, +} diff --git a/src/RoyalCode.SmartSearch.Demo/Search/SearchSetup.cs b/src/RoyalCode.SmartSearch.Demo/Search/SearchSetup.cs new file mode 100644 index 0000000..4c42e66 --- /dev/null +++ b/src/RoyalCode.SmartSearch.Demo/Search/SearchSetup.cs @@ -0,0 +1,74 @@ +using RoyalCode.OperationHint.Abstractions; +using RoyalCode.SmartSearch.Demo.Data; +using RoyalCode.SmartSearch.Demo.Domain; +using RoyalCode.SmartSearch.Demo.Dtos; +using RoyalCode.SmartSearch.Demo.Search; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Wires up SmartSearch for the demo: criteria services, selectors, named sortings, the native Like operator +/// and operation hints. +/// +public static class SearchSetup +{ + public static IServiceCollection AddDemoSearches(this IServiceCollection services) + { + services.AddEntityFrameworkSearches(cfg => + { + // Register ICriteria for the searchable entities. + cfg.Add(); + cfg.Add(); + cfg.Add(); + + // Registered selector: Select() (and MapSelectFirst) pick it up. + cfg.AddSelector(c => new CustomerDto + { + Id = c.Id, + Name = c.Name, + Email = c.Email, + City = c.MainAddress != null ? c.MainAddress.City : null, + }); + + // Registered selector: computes Total from the items and flattens the customer name. + cfg.AddSelector(o => new OrderSummaryDto + { + Id = o.Id, + Number = o.Number, + CreatedAt = o.CreatedAt, + Status = o.Status, + CustomerName = o.Customer.Name, + Total = o.Items.Sum(i => i.Quantity * i.UnitPrice), + }); + + // Named sortings usable via ?orderby= or ?orderby=-desc. + cfg.AddOrderBy("createdAt", o => o.CreatedAt); + cfg.AddOrderBy("number", o => o.Number); + cfg.AddOrderBy("customer", o => o.Customer.Name); + cfg.AddOrderBy("price", p => p.Price); + cfg.AddOrderBy("name", c => c.Name); + }); + + // Opt-in: emit Like as native SQL LIKE, so user wildcards (e.g. LikeWrap.None with 'ABC%') are honored. + services.AddEntityFrameworkLikeOperator(); + + // Map operation hints to EF includes for Order (applied on entity terminals, not on projections/Exists). + services.ConfigureOperationHints(registry => + { + registry.AddIncludesHandler((hint, includes) => + { + switch (hint) + { + case OrderHints.WithCustomer: + includes.IncludeReference(o => o.Customer); + break; + case OrderHints.WithItems: + includes.IncludeCollection(o => o.Items); + break; + } + }); + }); + + return services; + } +} diff --git a/src/RoyalCode.SmartSearch.Demo/appsettings.Development.json b/src/RoyalCode.SmartSearch.Demo/appsettings.Development.json new file mode 100644 index 0000000..3e1a225 --- /dev/null +++ b/src/RoyalCode.SmartSearch.Demo/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Information" + } + } +} diff --git a/src/RoyalCode.SmartSearch.Demo/appsettings.json b/src/RoyalCode.SmartSearch.Demo/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/src/RoyalCode.SmartSearch.Demo/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/RoyalCode.SmartSearch.EntityFramework.Npgsql/Extensions/NpgsqlSearchesServiceCollectionExtensions.cs b/src/RoyalCode.SmartSearch.EntityFramework.Npgsql/Extensions/NpgsqlSearchesServiceCollectionExtensions.cs new file mode 100644 index 0000000..bc16c31 --- /dev/null +++ b/src/RoyalCode.SmartSearch.EntityFramework.Npgsql/Extensions/NpgsqlSearchesServiceCollectionExtensions.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.DependencyInjection.Extensions; +using RoyalCode.SmartSearch; +using RoyalCode.SmartSearch.EntityFramework.Npgsql.Filtering; +using RoyalCode.SmartSearch.Linq.Filtering; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extensions methods for . +/// +public static class NpgsqlSearchesServiceCollectionExtensions +{ + /// + /// + /// Adds the PostgreSQL emission of criteria: + /// EF.Functions.ILike for (native ILIKE) + /// and EF.Functions.Like for the remaining cases. + /// + /// + /// The registration order matters (first-non-null-wins): the ILike factory is registered before the + /// EF Like factory, so insensitive criteria are handled by ILIKE. + /// + /// + /// The to add the services to. + /// The so that additional calls can be chained. + public static IServiceCollection AddNpgsqlLikeOperators(this IServiceCollection services) + { + services.TryAddEnumerable(ServiceDescriptor + .Singleton()); + services.AddEntityFrameworkLikeOperator(); + return services; + } +} diff --git a/src/RoyalCode.SmartSearch.EntityFramework.Npgsql/Filtering/NpgsqlILikeExpressionFactory.cs b/src/RoyalCode.SmartSearch.EntityFramework.Npgsql/Filtering/NpgsqlILikeExpressionFactory.cs new file mode 100644 index 0000000..c4e5d63 --- /dev/null +++ b/src/RoyalCode.SmartSearch.EntityFramework.Npgsql/Filtering/NpgsqlILikeExpressionFactory.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore; +using RoyalCode.SmartSearch.Linq.Filtering; +using System.Linq.Expressions; +using System.Reflection; + +namespace RoyalCode.SmartSearch.EntityFramework.Npgsql.Filtering; + +/// +/// +/// Emits case-insensitive criteria +/// () as EF.Functions.ILike(target, pattern), +/// the native PostgreSQL ILIKE operator — preferable to UPPER(...) LIKE UPPER(...). +/// +/// +/// Criteria that are not Like + Insensitive are not customized (returns null), letting the +/// next factory (e.g. EntityFrameworkLikeExpressionFactory) or the default emission handle them. +/// Register with AddNpgsqlLikeOperators, which puts this factory before the EF Like factory. +/// +/// +public sealed class NpgsqlILikeExpressionFactory : ICriterionOperatorExpressionFactory +{ + private static readonly MethodInfo ILikeMethod = typeof(NpgsqlDbFunctionsExtensions) + .GetMethod(nameof(NpgsqlDbFunctionsExtensions.ILike), [typeof(DbFunctions), typeof(string), typeof(string)])!; + + private static readonly MethodInfo ConcatMethod = typeof(string) + .GetMethod(nameof(string.Concat), [typeof(string), typeof(string), typeof(string)])!; + + private static readonly MemberExpression EfFunctions = + Expression.Property(null, typeof(EF).GetProperty(nameof(EF.Functions))!); + + /// + public Expression? TryCreate(in CriterionOperatorContext context) + { + if (context.Operator is not CriterionOperator.Like + || context.Case is not CriterionCase.Insensitive + || context.TargetMemberAccess.Type != typeof(string) + || context.FilterMemberAccess.Type != typeof(string)) + return null; + + var pattern = context.FilterMemberAccess; + + if (CriterionDefaults.ResolveWrap(context.Wrap)) + pattern = Expression.Call(ConcatMethod, Expression.Constant("%"), pattern, Expression.Constant("%")); + + Expression expression = Expression.Call(ILikeMethod, EfFunctions, context.TargetMemberAccess, pattern); + return context.Negation ? Expression.Not(expression) : expression; + } +} diff --git a/src/RoyalCode.SmartSearch.EntityFramework.Npgsql/RoyalCode.SmartSearch.EntityFramework.Npgsql.csproj b/src/RoyalCode.SmartSearch.EntityFramework.Npgsql/RoyalCode.SmartSearch.EntityFramework.Npgsql.csproj new file mode 100644 index 0000000..fb7dd5c --- /dev/null +++ b/src/RoyalCode.SmartSearch.EntityFramework.Npgsql/RoyalCode.SmartSearch.EntityFramework.Npgsql.csproj @@ -0,0 +1,27 @@ + + + + + + $(AspTargets) + + + + + PostgreSQL (Npgsql) emission for SmartSearch criteria, + including ILIKE for case-insensitive Like filters. + + + RoyalCode Enterprise-Patterns Persistence Searchable Search Filter-Specifier-Pattern PostgreSQL Npgsql + + + + + + + + + + + + diff --git a/src/RoyalCode.SmartSearch.EntityFramework/Extensions/EntityFrameworkSearchesServiceCollectionExtensions.cs b/src/RoyalCode.SmartSearch.EntityFramework/Extensions/EntityFrameworkSearchesServiceCollectionExtensions.cs index e84d2ee..de15ff1 100644 --- a/src/RoyalCode.SmartSearch.EntityFramework/Extensions/EntityFrameworkSearchesServiceCollectionExtensions.cs +++ b/src/RoyalCode.SmartSearch.EntityFramework/Extensions/EntityFrameworkSearchesServiceCollectionExtensions.cs @@ -2,8 +2,10 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using RoyalCode.SmartSearch; using RoyalCode.SmartSearch.EntityFramework.Configurations; +using RoyalCode.SmartSearch.EntityFramework.Filtering; using RoyalCode.SmartSearch.EntityFramework.Services; using RoyalCode.SmartSearch.Linq; +using RoyalCode.SmartSearch.Linq.Filtering; namespace Microsoft.Extensions.DependencyInjection; @@ -68,4 +70,23 @@ public static IServiceCollection AddSearchManager(this IServiceColle return services; } + + /// + /// + /// Adds the emission of criteria as + /// EF.Functions.Like(target, pattern) (native LIKE, user wildcards honored by the + /// provider). Opt-in: without this registration the portable emission is used. + /// + /// + /// See . + /// + /// + /// The to add the services to. + /// The so that additional calls can be chained. + public static IServiceCollection AddEntityFrameworkLikeOperator(this IServiceCollection services) + { + services.TryAddEnumerable(ServiceDescriptor + .Singleton()); + return services; + } } diff --git a/src/RoyalCode.SmartSearch.EntityFramework/Filtering/EntityFrameworkLikeExpressionFactory.cs b/src/RoyalCode.SmartSearch.EntityFramework/Filtering/EntityFrameworkLikeExpressionFactory.cs new file mode 100644 index 0000000..2e317b2 --- /dev/null +++ b/src/RoyalCode.SmartSearch.EntityFramework/Filtering/EntityFrameworkLikeExpressionFactory.cs @@ -0,0 +1,60 @@ +using Microsoft.EntityFrameworkCore; +using RoyalCode.SmartSearch.Linq.Filtering; +using System.Linq.Expressions; +using System.Reflection; + +namespace RoyalCode.SmartSearch.EntityFramework.Filtering; + +/// +/// +/// Emits criteria as EF.Functions.Like(target, pattern), +/// translated to the native LIKE by relational providers. User wildcards (% and _) +/// are honored by the provider. +/// +/// +/// With , both sides are normalized with ToUpper() +/// (UPPER(...) LIKE UPPER(...)), since ILIKE is provider-specific (see the Npgsql package). +/// +/// +/// Opt-in: register with AddEntityFrameworkLikeOperator. Only queries executed by a provider that +/// translates EF.Functions are supported (the expression is not executable in memory). +/// +/// +public sealed class EntityFrameworkLikeExpressionFactory : ICriterionOperatorExpressionFactory +{ + private static readonly MethodInfo LikeMethod = typeof(DbFunctionsExtensions) + .GetMethod(nameof(DbFunctionsExtensions.Like), [typeof(DbFunctions), typeof(string), typeof(string)])!; + + private static readonly MethodInfo ConcatMethod = typeof(string) + .GetMethod(nameof(string.Concat), [typeof(string), typeof(string), typeof(string)])!; + + private static readonly MethodInfo ToUpperMethod = typeof(string) + .GetMethod(nameof(string.ToUpper), Type.EmptyTypes)!; + + private static readonly MemberExpression EfFunctions = + Expression.Property(null, typeof(EF).GetProperty(nameof(EF.Functions))!); + + /// + public Expression? TryCreate(in CriterionOperatorContext context) + { + if (context.Operator is not CriterionOperator.Like + || context.TargetMemberAccess.Type != typeof(string) + || context.FilterMemberAccess.Type != typeof(string)) + return null; + + var target = context.TargetMemberAccess; + var pattern = context.FilterMemberAccess; + + if (context.Case == CriterionCase.Insensitive) + { + target = Expression.Call(target, ToUpperMethod); + pattern = Expression.Call(pattern, ToUpperMethod); + } + + if (CriterionDefaults.ResolveWrap(context.Wrap)) + pattern = Expression.Call(ConcatMethod, Expression.Constant("%"), pattern, Expression.Constant("%")); + + Expression expression = Expression.Call(LikeMethod, EfFunctions, target, pattern); + return context.Negation ? Expression.Not(expression) : expression; + } +} diff --git a/src/RoyalCode.SmartSearch.Linq/Filtering/CriterionDefaults.cs b/src/RoyalCode.SmartSearch.Linq/Filtering/CriterionDefaults.cs new file mode 100644 index 0000000..01ac79f --- /dev/null +++ b/src/RoyalCode.SmartSearch.Linq/Filtering/CriterionDefaults.cs @@ -0,0 +1,52 @@ +namespace RoyalCode.SmartSearch; + +/// +/// +/// Global defaults for the generation of criterion expressions. +/// +/// +/// These values are read when the specifier functions are generated (and cached), therefore they must be +/// configured at application startup, before any search is performed. +/// +/// +public static class CriterionDefaults +{ + /// + /// + /// The operator applied to string filter properties when the criterion operator is + /// . The default is , + /// where user wildcards (%) are honored. + /// + /// + /// Set to to restore literal substring matching + /// (wildcards escaped) as the default for strings. + /// + /// + public static CriterionOperator DefaultStringOperator { get; set; } = CriterionOperator.Like; + + /// + /// + /// Whether values are wrapped with wildcards (%value%) + /// by default. Can be overridden per criterion with . + /// + /// + /// The default is : values match as substrings, and user wildcards inside the + /// value are honored. When , the value is the pattern as-is (LIKE semantics: + /// without wildcards the match is exact). + /// + /// + public static bool WrapLikeValue { get; set; } = true; + + /// + /// Resolves the effective wrap behavior for a criterion, combining the per-criterion override + /// with the global default (). + /// + /// The per-criterion override. + /// True when the like value must be wrapped with wildcards. + public static bool ResolveWrap(LikeWrap wrap) => wrap switch + { + LikeWrap.Wrap => true, + LikeWrap.None => false, + _ => WrapLikeValue, + }; +} diff --git a/src/RoyalCode.SmartSearch.Linq/Filtering/CriterionOperatorExpressionFactories.cs b/src/RoyalCode.SmartSearch.Linq/Filtering/CriterionOperatorExpressionFactories.cs new file mode 100644 index 0000000..73f4e0f --- /dev/null +++ b/src/RoyalCode.SmartSearch.Linq/Filtering/CriterionOperatorExpressionFactories.cs @@ -0,0 +1,53 @@ +using System.Linq.Expressions; + +namespace RoyalCode.SmartSearch.Linq.Filtering; + +/// +/// +/// Encapsulates the registered instances and applies +/// the first-non-null-wins policy over them, in registration order. +/// +/// +/// This is the component that flows through the specifier generation pipeline; the raw enumerable of +/// factories is never passed around. +/// +/// +public sealed class CriterionOperatorExpressionFactories +{ + /// + /// An instance without factories: always returns . + /// + public static CriterionOperatorExpressionFactories Empty { get; } = new([]); + + private readonly ICriterionOperatorExpressionFactory[] factories; + + /// + /// Creates a new instance encapsulating the given factories. + /// + /// The factories, in the order they must be tried. + public CriterionOperatorExpressionFactories(IEnumerable factories) + { + this.factories = [.. factories]; + } + + /// + /// Tries to create the operator expression using the registered factories, in order. + /// The first non-null result wins. + /// + /// The criterion operator context. + /// + /// The customized expression, or when no factory customizes the given context + /// (the default emission must be used). + /// + public Expression? TryCreate(in CriterionOperatorContext context) + { + foreach (var factory in factories) + { + var expression = factory.TryCreate(in context); + if (expression is not null) + return expression; + } + + return null; + } +} diff --git a/src/RoyalCode.SmartSearch.Linq/Filtering/CriterionResolutions.cs b/src/RoyalCode.SmartSearch.Linq/Filtering/CriterionResolutions.cs index e9b48fe..d7320ea 100644 --- a/src/RoyalCode.SmartSearch.Linq/Filtering/CriterionResolutions.cs +++ b/src/RoyalCode.SmartSearch.Linq/Filtering/CriterionResolutions.cs @@ -8,6 +8,7 @@ namespace RoyalCode.SmartSearch.Linq.Filtering; internal static class CriterionResolutions { public static IReadOnlyList CreateResolutions( + CriterionOperatorExpressionFactories factories, PropertySelection? previousFilterProperty = null, FilterTarget? filterTarget = null) where TModel : class where TFilter : class @@ -18,26 +19,27 @@ public static IReadOnlyList CreateResolutions(available, filterTarget, resolutions); - BuildDisjunctionsFromAttributes(available, filterTarget, resolutions); - BuildDisjunctionsFromNameOrTargetPath(available, filterTarget, resolutions); - BuildComplexFilterResolutions(available, filterTarget, resolutions); + BuildDisjunctionsFromAttributes(available, filterTarget, resolutions, factories); + BuildDisjunctionsFromNameOrTargetPath(available, filterTarget, resolutions, factories); + BuildComplexFilterResolutions(available, filterTarget, resolutions, factories); BuildFilterExpressionGenerator(available, filterTarget, resolutions); - BuildDefaultOperatorResolutions(available, filterTarget, resolutions); + BuildDefaultOperatorResolutions(available, filterTarget, resolutions, factories); return resolutions; } public static IReadOnlyList CreateResolutions( - PropertySelection previousFilterProperty, FilterTarget filterTarget) + PropertySelection previousFilterProperty, FilterTarget filterTarget, + CriterionOperatorExpressionFactories factories) { List resolutions = []; var available = BuildAvailableFilterProperties(previousFilterProperty.Info.PropertyType, previousFilterProperty); - BuildDisjunctionsFromAttributes(available, filterTarget, resolutions); - BuildDisjunctionsFromNameOrTargetPath(available, filterTarget, resolutions); - BuildComplexFilterResolutions(available, filterTarget, resolutions); + BuildDisjunctionsFromAttributes(available, filterTarget, resolutions, factories); + BuildDisjunctionsFromNameOrTargetPath(available, filterTarget, resolutions, factories); + BuildComplexFilterResolutions(available, filterTarget, resolutions, factories); BuildFilterExpressionGenerator(available, filterTarget, resolutions); - BuildDefaultOperatorResolutions(available, filterTarget, resolutions); + BuildDefaultOperatorResolutions(available, filterTarget, resolutions, factories); return resolutions; } @@ -88,37 +90,39 @@ private static void ApplyCustomPredicateFactories( } private static void BuildDisjunctionsFromAttributes( - List available, - FilterTarget filterTarget, - List resolutions) + List available, + FilterTarget filterTarget, + List resolutions, + CriterionOperatorExpressionFactories factories) { - var disjuctionsElected = available - .Where(t => t.FilterProperty.Info.IsDefined(typeof(DisjuctionAttribute), true)) + var disjunctionsElected = available + .Where(t => t.FilterProperty.Info.IsDefined(typeof(DisjunctionAttribute), true)) .Select(t => new { Property = t, - Group = t.FilterProperty.Info.GetCustomAttribute(true)!.Alias + Group = t.FilterProperty.Info.GetCustomAttribute(true)!.Alias }) .ToList(); - if (disjuctionsElected.Count is not 0) + if (disjunctionsElected.Count is not 0) { - var disjuctions = disjuctionsElected + var disjunctions = disjunctionsElected .GroupBy(t => t.Group) - .Select(g => new DisjuctionCriterionResolution( + .Select(g => new DisjunctionCriterionResolution( filterTarget, - [.. g.Select(t => new JunctionProperty(t.Property.FilterProperty, t.Property.Criterion, filterTarget))])) + [.. g.Select(t => new JunctionProperty(t.Property.FilterProperty, t.Property.Criterion, filterTarget, factories))])) .ToList(); - resolutions.AddRange(disjuctions); - disjuctionsElected.ForEach(de => available.Remove(de.Property)); + resolutions.AddRange(disjunctions); + disjunctionsElected.ForEach(de => available.Remove(de.Property)); } } private static void BuildDisjunctionsFromNameOrTargetPath( List available, FilterTarget filterTarget, - List resolutions) + List resolutions, + CriterionOperatorExpressionFactories factories) { var junctionsElected = available .Where(t => t.Criterion.DisableOrFromName is false) @@ -135,7 +139,7 @@ private static void BuildDisjunctionsFromNameOrTargetPath( if (junctionsElected.Count is not 0) { var junctions = junctionsElected - .Select(j => new DisjuctionCriterionResolution( + .Select(j => new DisjunctionCriterionResolution( filterTarget, [.. j.Parts.Select(part => new JunctionProperty( j.Property.FilterProperty, @@ -145,8 +149,11 @@ private static void BuildDisjunctionsFromNameOrTargetPath( Negation = j.Property.Criterion.Negation, IgnoreIfIsEmpty = j.Property.Criterion.IgnoreIfIsEmpty, TargetPropertyPath = part, + Case = j.Property.Criterion.Case, + Wrap = j.Property.Criterion.Wrap, }, - filterTarget)) + filterTarget, + factories)) ])) .ToList(); @@ -157,8 +164,9 @@ private static void BuildDisjunctionsFromNameOrTargetPath( private static void BuildComplexFilterResolutions( List available, - FilterTarget filterTarget, - List resolutions) + FilterTarget filterTarget, + List resolutions, + CriterionOperatorExpressionFactories factories) { var complexElected = available .Where(t => @@ -172,7 +180,8 @@ private static void BuildComplexFilterResolutions( .Select(t => new ComplexFilterCriterionResolution( t.FilterProperty, t.Criterion, - filterTarget)) + filterTarget, + factories)) .ToList(); resolutions.AddRange(complexResolutions); @@ -218,12 +227,13 @@ private static void BuildFilterExpressionGenerator( private static void BuildDefaultOperatorResolutions( List available, - FilterTarget filterTarget, - List resolutions) + FilterTarget filterTarget, + List resolutions, + CriterionOperatorExpressionFactories factories) { foreach (var t in available) { - resolutions.Add(new DefaultOperatorCriterionResolution(t.FilterProperty, t.Criterion, filterTarget)); + resolutions.Add(new DefaultOperatorCriterionResolution(t.FilterProperty, t.Criterion, filterTarget, factories)); } } @@ -232,4 +242,4 @@ private sealed class AvailableFilterProperty public required PropertySelection FilterProperty { get; init; } public required CriterionAttribute Criterion { get; init; } } -} \ No newline at end of file +} diff --git a/src/RoyalCode.SmartSearch.Linq/Filtering/DefaultSpecifierFunctionGenerator.cs b/src/RoyalCode.SmartSearch.Linq/Filtering/DefaultSpecifierFunctionGenerator.cs index 114c033..d3b04d3 100644 --- a/src/RoyalCode.SmartSearch.Linq/Filtering/DefaultSpecifierFunctionGenerator.cs +++ b/src/RoyalCode.SmartSearch.Linq/Filtering/DefaultSpecifierFunctionGenerator.cs @@ -10,12 +10,26 @@ namespace RoyalCode.SmartSearch.Linq.Filtering; /// public sealed class DefaultSpecifierFunctionGenerator : ISpecifierFunctionGenerator { + private readonly CriterionOperatorExpressionFactories operatorFactories; + + /// + /// Creates a new generator. + /// + /// + /// Optional factories that customize the emission of criterion operator expressions + /// (first-non-null-wins, in registration order). + /// + public DefaultSpecifierFunctionGenerator(CriterionOperatorExpressionFactories? operatorFactories = null) + { + this.operatorFactories = operatorFactories ?? CriterionOperatorExpressionFactories.Empty; + } + /// public SpecifierFunctionGenerationResult Generate() where TModel : class where TFilter : class { - var resolutions = CriterionResolutions.CreateResolutions(); + var resolutions = CriterionResolutions.CreateResolutions(operatorFactories); // check if all resolution are satisfied, if any lack, then return. if (Lack.CheckLacks(out var lacks, resolutions)) diff --git a/src/RoyalCode.SmartSearch.Linq/Filtering/ExpressionGenerator.cs b/src/RoyalCode.SmartSearch.Linq/Filtering/ExpressionGenerator.cs index a7292d7..ed1e54f 100644 --- a/src/RoyalCode.SmartSearch.Linq/Filtering/ExpressionGenerator.cs +++ b/src/RoyalCode.SmartSearch.Linq/Filtering/ExpressionGenerator.cs @@ -62,6 +62,12 @@ public static class ExpressionGenerator public static readonly MethodInfo EndsWithMethod = typeof(string) .GetMethod(nameof(string.EndsWith), [typeof(string)])!; + /// + /// ToUpper Method of string, without parameters (translatable by relational providers). + /// + public static readonly MethodInfo ToUpperMethod = typeof(string) + .GetMethod(nameof(string.ToUpper), Type.EmptyTypes)!; + /// /// Where method of to call over . /// @@ -85,6 +91,48 @@ public static class ExpressionGenerator #endregion + /// + /// + /// Creates the expression that performs the comparison between the model property and the filter + /// property, applying the declared case sensitivity for string operators. + /// + /// + /// With and string operands, both sides are normalized with + /// ToUpper() (portable fallback; translatable by relational providers). For non-string + /// operands or other operators, the case declaration is ignored. + /// + /// + /// The operator to be used in the comparison. + /// Indicates whether the comparison should be negated. + /// The expression that represents the filter property. + /// The expression that represents the model property. + /// The declared case sensitivity. + /// The expression that performs the comparison. + /// + /// The operator is not supported. + /// + public static Expression CreateOperatorExpression( + CriterionOperator @operator, + bool negation, + Expression filterMemberAccess, + Expression targetMemberAccess, + CriterionCase criterionCase) + { + if (criterionCase == CriterionCase.Insensitive + && targetMemberAccess.Type == typeof(string) + && filterMemberAccess.Type == typeof(string) + && @operator is CriterionOperator.Like + or CriterionOperator.Contains + or CriterionOperator.StartsWith + or CriterionOperator.EndsWith) + { + targetMemberAccess = Expression.Call(targetMemberAccess, ToUpperMethod); + filterMemberAccess = Expression.Call(filterMemberAccess, ToUpperMethod); + } + + return CreateOperatorExpression(@operator, negation, filterMemberAccess, targetMemberAccess); + } + /// /// /// Creates the expression that performs the comparison between the model property and the filter property. @@ -185,7 +233,8 @@ public static CriterionOperator DiscoveryCriterionOperator( } if (filterProperty.PropertyType == typeof(string)) { - return CriterionOperator.Like; + // configuravel: Like (default, curingas honrados) ou Contains (substring literal) + return CriterionDefaults.DefaultStringOperator; } else if (typeof(IEnumerable).IsAssignableFrom(filterProperty.PropertyType)) { diff --git a/src/RoyalCode.SmartSearch.Linq/Filtering/ICriterionOperatorExpressionFactory.cs b/src/RoyalCode.SmartSearch.Linq/Filtering/ICriterionOperatorExpressionFactory.cs new file mode 100644 index 0000000..7c6dd16 --- /dev/null +++ b/src/RoyalCode.SmartSearch.Linq/Filtering/ICriterionOperatorExpressionFactory.cs @@ -0,0 +1,119 @@ +using System.Linq.Expressions; +using System.Reflection; + +namespace RoyalCode.SmartSearch.Linq.Filtering; + +/// +/// +/// Factory that customizes the expression emitted for a criterion operator. +/// +/// +/// Implementations are registered in the service collection and tried, in registration order, before the +/// default emission. Returning means the factory does not customize the given +/// context, and the next factory (or the default emission) is used. +/// +/// +/// The customization covers generated criteria ( and disjunctions). +/// Hand-written expressions (manual specifiers, predicate factories) are the consumer's responsibility +/// and are not affected. +/// +/// +/// Note: generated specifier functions are cached per process, keyed by model and filter types. In +/// practice this means one emission strategy per model per process — distinct service providers in the +/// same process share the generated specifiers. +/// +/// +public interface ICriterionOperatorExpressionFactory +{ + /// + /// Tries to create the operator expression for the given context. + /// + /// The criterion operator context. + /// + /// The boolean expression that performs the comparison, or when this factory + /// does not customize the given context. + /// + Expression? TryCreate(in CriterionOperatorContext context); +} + +/// +/// The context of a criterion operator expression to be created. +/// +public readonly struct CriterionOperatorContext +{ + /// + /// Creates a new context. + /// + /// The resolved criterion operator (never ). + /// The declared case sensitivity. + /// The per-criterion like wrap override. + /// Whether the comparison is negated. + /// + /// The expression of the filter value: a member access at generation time, or a constant with the + /// actual value in the disjunction (runtime) path. + /// + /// The expression that accesses the target model property. + /// The filter property, when available. + /// The query source model type. + public CriterionOperatorContext( + CriterionOperator @operator, + CriterionCase @case, + LikeWrap wrap, + bool negation, + Expression filterMemberAccess, + Expression targetMemberAccess, + PropertyInfo? filterProperty, + Type modelType) + { + Operator = @operator; + Case = @case; + Wrap = wrap; + Negation = negation; + FilterMemberAccess = filterMemberAccess; + TargetMemberAccess = targetMemberAccess; + FilterProperty = filterProperty; + ModelType = modelType; + } + + /// + /// The resolved criterion operator (never ). + /// + public CriterionOperator Operator { get; } + + /// + /// The declared case sensitivity. + /// + public CriterionCase Case { get; } + + /// + /// The per-criterion like wrap override. Resolve the effective value with + /// . + /// + public LikeWrap Wrap { get; } + + /// + /// Whether the comparison is negated. + /// + public bool Negation { get; } + + /// + /// The expression of the filter value: a member access at generation time, or a constant with the + /// actual value in the disjunction (runtime) path. + /// + public Expression FilterMemberAccess { get; } + + /// + /// The expression that accesses the target model property. + /// + public Expression TargetMemberAccess { get; } + + /// + /// The filter property, when available. + /// + public PropertyInfo? FilterProperty { get; } + + /// + /// The query source model type. + /// + public Type ModelType { get; } +} diff --git a/src/RoyalCode.SmartSearch.Linq/Filtering/LikeExpressionGenerator.cs b/src/RoyalCode.SmartSearch.Linq/Filtering/LikeExpressionGenerator.cs new file mode 100644 index 0000000..6a9de98 --- /dev/null +++ b/src/RoyalCode.SmartSearch.Linq/Filtering/LikeExpressionGenerator.cs @@ -0,0 +1,173 @@ +using System.Linq.Expressions; +using System.Reflection; + +namespace RoyalCode.SmartSearch.Linq.Filtering; + +/// +/// +/// Portable generation of expressions, honoring user wildcards +/// (%) without depending on EF or provider-specific functions. +/// +/// +/// The pattern is matched with a greedy algorithm composed only of translatable pieces +/// (StartsWith, EndsWith, Contains, IndexOf, Substring): +/// anchors when the pattern does not start/end with %, and in-order middle segments by slicing +/// the remaining string after each match. The same expression tree is translatable by relational +/// providers and executable in memory (LINQ to Objects). +/// +/// +/// The _ wildcard is not supported in the portable mode (it is honored by provider factories such +/// as EF.Functions.Like). +/// +/// +public static class LikeExpressionGenerator +{ + /// + /// + /// Maximum number of slice operations (in-order middle segments). Each slice doubles the size of the + /// generated expression, so the composition is cut here; segments beyond the cut are checked with a + /// plain Contains over the whole value (documented approximation: order is not guaranteed for + /// the excess segments). + /// + /// + public const int MaxSliceOperations = 5; + + private static readonly MethodInfo IndexOfMethod = typeof(string) + .GetMethod(nameof(string.IndexOf), [typeof(string)])!; + + private static readonly MethodInfo SubstringMethod = typeof(string) + .GetMethod(nameof(string.Substring), [typeof(int)])!; + + private static readonly MethodInfo ApplyMethod = typeof(LikeExpressionGenerator) + .GetMethod(nameof(Apply))!; + + internal static MethodInfo GetApplyMethod(Type modelType) => ApplyMethod.MakeGenericMethod(modelType); + + /// + /// + /// Applies the like pattern to the query. Called at runtime from generated specifier code, because + /// the shape of the predicate depends on the value (wildcards), which only exists at execution time. + /// + /// + /// The query source model type. + /// The query to filter. + /// The filter value (the pattern, before the optional wrap). + /// The expression that selects the target string property. + /// Whether the value is wrapped with wildcards (%value%). + /// Whether both sides are normalized with ToUpper(). + /// Whether the comparison is negated. + /// The filtered query. + public static IQueryable Apply( + IQueryable query, + string? value, + Expression> target, + bool wrap, + bool ignoreCase, + bool negation) + { + // defensivo: criterios com IgnoreIfIsEmpty ja evitam chegar aqui com valor vazio + if (string.IsNullOrEmpty(value)) + return query; + + var parameter = target.Parameters[0]; + var predicate = CreatePatternExpression(target.Body, value, wrap, ignoreCase); + if (negation) + predicate = Expression.Not(predicate); + + var lambda = Expression.Lambda>(predicate, parameter); + return query.Where(lambda); + } + + /// + /// + /// Creates the boolean expression that matches the like pattern against the target string expression. + /// + /// + /// The expression that accesses the target string value. + /// The filter value (the pattern, before the optional wrap). + /// Whether the value is wrapped with wildcards (%value%). + /// Whether both sides are normalized with ToUpper(). + /// The boolean expression of the pattern match. + public static Expression CreatePatternExpression( + Expression targetAccess, + string value, + bool wrap, + bool ignoreCase) + { + if (ignoreCase) + { + targetAccess = Expression.Call(targetAccess, ExpressionGenerator.ToUpperMethod); + value = value.ToUpper(); + } + + var pattern = wrap ? "%" + value + "%" : value; + var leading = pattern.Length > 0 && pattern[0] == '%'; + var trailing = pattern.Length > 0 && pattern[^1] == '%'; + var segments = pattern.Split('%', StringSplitOptions.RemoveEmptyEntries); + + // pattern feito so de curingas: tudo da match; pattern vazio: igualdade com vazio + if (segments.Length == 0) + return leading || trailing + ? Expression.Constant(true) + : Expression.Equal(targetAccess, Expression.Constant(string.Empty)); + + // sem curingas: igualdade exata (semantica do LIKE; com o wrap default nao ocorre) + if (!leading && !trailing && segments.Length == 1) + return Expression.Equal(targetAccess, Expression.Constant(segments[0])); + + List conditions = []; + var current = targetAccess; + var slices = 0; + var first = 0; + var last = segments.Length - 1; + + if (!leading) + { + // ancora inicial: o primeiro segmento e prefixo; consome-o fatiando o restante + conditions.Add(Expression.Call(current, ExpressionGenerator.StartsWithMethod, Expression.Constant(segments[first]))); + current = Expression.Call(current, SubstringMethod, Expression.Constant(segments[first].Length)); + first++; + } + + // segmentos "do meio": todos, exceto a ancora final quando o pattern nao termina com % + var middleEnd = trailing ? last : last - 1; + + for (var i = first; i <= middleEnd; i++) + { + var segment = Expression.Constant(segments[i]); + + if (slices >= MaxSliceOperations) + { + // corte: excedentes verificados sobre o valor inteiro, sem garantia de ordem + conditions.Add(Expression.Call(targetAccess, ExpressionGenerator.ContainsMethod, segment)); + continue; + } + + // o segmento deve ocorrer na fatia restante (ordem garantida pelo fatiamento anterior) + conditions.Add(Expression.Call(current, ExpressionGenerator.ContainsMethod, segment)); + + // fatia apenas se ainda ha segmentos a consumir depois deste + if (i < middleEnd || !trailing) + { + var next = Expression.Add( + Expression.Call(current, IndexOfMethod, segment), + Expression.Constant(segments[i].Length)); + current = Expression.Call(current, SubstringMethod, next); + slices++; + } + } + + if (!trailing) + { + // ancora final: o ultimo segmento deve encerrar a fatia restante + // (EndsWith sobre a fatia impede sobreposicao com os segmentos ja consumidos) + conditions.Add(Expression.Call(current, ExpressionGenerator.EndsWithMethod, Expression.Constant(segments[last]))); + } + + var result = conditions[0]; + for (var i = 1; i < conditions.Count; i++) + result = Expression.AndAlso(result, conditions[i]); + + return result; + } +} diff --git a/src/RoyalCode.SmartSearch.Linq/Filtering/Resolutions/ComplexFilterCriterionResolution.cs b/src/RoyalCode.SmartSearch.Linq/Filtering/Resolutions/ComplexFilterCriterionResolution.cs index 1b51874..979c394 100644 --- a/src/RoyalCode.SmartSearch.Linq/Filtering/Resolutions/ComplexFilterCriterionResolution.cs +++ b/src/RoyalCode.SmartSearch.Linq/Filtering/Resolutions/ComplexFilterCriterionResolution.cs @@ -10,6 +10,7 @@ internal class ComplexFilterCriterionResolution : ICriterionResolution private readonly Type? filterPropertyUnderlyingType; private readonly CriterionAttribute criterion; private readonly FilterTarget filterTarget; + private readonly CriterionOperatorExpressionFactories factories; private readonly IReadOnlyList internalResolutions; private Lack? lack; @@ -17,11 +18,13 @@ internal class ComplexFilterCriterionResolution : ICriterionResolution public ComplexFilterCriterionResolution( PropertySelection filterProperty, CriterionAttribute criterion, - FilterTarget filterTarget) + FilterTarget filterTarget, + CriterionOperatorExpressionFactories factories) { this.filterProperty = filterProperty; this.criterion = criterion; this.filterTarget = filterTarget; + this.factories = factories; filterPropertyUnderlyingType = Nullable.GetUnderlyingType(filterProperty.PropertyType); internalResolutions = CreateInternalCriterionResolution(); @@ -99,6 +102,6 @@ private IReadOnlyList CreateInternalCriterionResolution() : filterProperty; // return resolutions for the filter property and the new filter target - return CriterionResolutions.CreateResolutions(previousFilterProperty, newFilterTarget); + return CriterionResolutions.CreateResolutions(previousFilterProperty, newFilterTarget, factories); } } diff --git a/src/RoyalCode.SmartSearch.Linq/Filtering/Resolutions/DefaultOperatorCriterionResolution.cs b/src/RoyalCode.SmartSearch.Linq/Filtering/Resolutions/DefaultOperatorCriterionResolution.cs index c8633df..c876e96 100644 --- a/src/RoyalCode.SmartSearch.Linq/Filtering/Resolutions/DefaultOperatorCriterionResolution.cs +++ b/src/RoyalCode.SmartSearch.Linq/Filtering/Resolutions/DefaultOperatorCriterionResolution.cs @@ -1,15 +1,23 @@ -using RoyalCode.Extensions.PropertySelection; +using RoyalCode.Extensions.PropertySelection; using System.Linq.Expressions; namespace RoyalCode.SmartSearch.Linq.Filtering.Resolutions; internal class DefaultOperatorCriterionResolution : AbstractCriterionResolution { - private PropertySelection? targetSelection; + private readonly CriterionOperatorExpressionFactories factories; + private readonly PropertySelection? targetSelection; + private readonly CriterionOperator @operator; - public DefaultOperatorCriterionResolution(PropertySelection property, CriterionAttribute criterionAttribute, FilterTarget filterTarget) + public DefaultOperatorCriterionResolution( + PropertySelection property, + CriterionAttribute criterionAttribute, + FilterTarget filterTarget, + CriterionOperatorExpressionFactories factories) : base(property, criterionAttribute, filterTarget) { + this.factories = factories; + var targetProperty = Criterion.TargetPropertyPath ?? FilterPropertySelection.PropertyName; targetSelection = filterTarget.TrySelectProperty(targetProperty); @@ -28,27 +36,86 @@ public DefaultOperatorCriterionResolution(PropertySelection property, CriterionA Description = $"The target property '{targetSelection}' for filter property '{FilterPropertySelection}' has incompatible type '{targetSelection.PropertyType.FullName}' (filter property type: '{FilterPropertySelection.PropertyType.FullName}')." }; } + + @operator = ExpressionGenerator.DiscoveryCriterionOperator(criterionAttribute, property.Info); } - protected override Expression CreatePredicateExpression(ParameterExpression filterParam) + public override Expression CreateExpression(ParameterExpression queryParam, ParameterExpression filterParam) { + if (IsLacking(out var lack)) + throw lack.ToException(); + // the predicate function parameter, the entity/model of the query. var targetParam = Expression.Parameter(FilterTarget.ModelType, "e"); + var filterMemberAccess = FilterPropertySelection.GetMemberAccess(filterParam); + var targetMemberAccess = targetSelection!.GetMemberAccess(targetParam); - var operatorExpression = ExpressionGenerator.CreateOperatorExpression( - ExpressionGenerator.DiscoveryCriterionOperator(Criterion, FilterPropertySelection.Info), + var context = new CriterionOperatorContext( + @operator, + Criterion.Case, + Criterion.Wrap, Criterion.Negation, - FilterPropertySelection.GetMemberAccess(filterParam), - targetSelection!.GetMemberAccess(targetParam)); + filterMemberAccess, + targetMemberAccess, + FilterPropertySelection.Info, + FilterTarget.ModelType); - // generate the type of the predicate. - var predicateType = typeof(Func<,>).MakeGenericType( - FilterTarget.ModelType, - typeof(bool)); + // factories registradas tem a primeira chance de customizar a expressao do operador + var operatorExpression = factories.TryCreate(in context); - // create the lambda expression for the queryable - var lambda = Expression.Lambda(predicateType, operatorExpression, targetParam); + Expression assign; + if (operatorExpression is null + && @operator is CriterionOperator.Like + && targetMemberAccess.Type == typeof(string) + && filterMemberAccess.Type == typeof(string)) + { + // Like portavel: a forma do predicado depende do valor (curingas), que so existe na execucao; + // emite chamada ao helper de runtime em vez de um predicado fixo. + var targetLambda = Expression.Lambda( + typeof(Func<,>).MakeGenericType(FilterTarget.ModelType, typeof(string)), + targetMemberAccess, + targetParam); + + assign = Expression.Assign(queryParam, Expression.Call( + LikeExpressionGenerator.GetApplyMethod(FilterTarget.ModelType), + queryParam, + filterMemberAccess, + Expression.Constant(targetLambda), + Expression.Constant(CriterionDefaults.ResolveWrap(Criterion.Wrap)), + Expression.Constant(Criterion.Case == CriterionCase.Insensitive), + Expression.Constant(Criterion.Negation))); + } + else + { + operatorExpression ??= ExpressionGenerator.CreateOperatorExpression( + @operator, + Criterion.Negation, + filterMemberAccess, + targetMemberAccess, + Criterion.Case); - return lambda; + // generate the type of the predicate. + var predicateType = typeof(Func<,>).MakeGenericType(FilterTarget.ModelType, typeof(bool)); + + // create the lambda expression for the queryable + var lambda = Expression.Lambda(predicateType, operatorExpression, targetParam); + + assign = Expression.Assign( + queryParam, + ExpressionGenerator.CreateWhereCall(FilterTarget.ModelType, queryParam, lambda)); + } + + // create an expression to check if the filter property is empty + if (Criterion.IgnoreIfIsEmpty) + assign = ExpressionGenerator.GetIfIsEmptyConstraintExpression( + FilterPropertySelection.GetAccessExpression(filterParam), + assign); + + return assign; } -} \ No newline at end of file + + // nao usado: CreateExpression e totalmente substituido porque o Like portavel + // nao produz um predicado fixo (a aplicacao e delegada ao helper de runtime). + protected override Expression CreatePredicateExpression(ParameterExpression filterParam) + => throw new NotSupportedException("CreateExpression is fully overridden by this resolution."); +} diff --git a/src/RoyalCode.SmartSearch.Linq/Filtering/Resolutions/DisjunctionContext.cs b/src/RoyalCode.SmartSearch.Linq/Filtering/Resolutions/DisjunctionContext.cs index ac0737f..6442bbe 100644 --- a/src/RoyalCode.SmartSearch.Linq/Filtering/Resolutions/DisjunctionContext.cs +++ b/src/RoyalCode.SmartSearch.Linq/Filtering/Resolutions/DisjunctionContext.cs @@ -17,11 +17,43 @@ public void Append(JunctionProperty junction, TProperty value) var targetAccess = junction.ModelPropertySelection!.GetMemberAccess(LambdaParam); var valueExpr = Expression.Constant(value, typeof(TProperty)); - Expression opExpr = ExpressionGenerator.CreateOperatorExpression( + // este caminho executa em runtime (valor real inline): as mesmas customizacoes da geracao + // (factories, Like portavel, case) devem valer aqui, senao filtros "NomeOrApelido" divergem. + var context = new CriterionOperatorContext( junction.Operator, - junction.Criterion.Negation, + criterion.Case, + criterion.Wrap, + criterion.Negation, + valueExpr, targetAccess, - valueExpr); + junction.FilterProperty.Info, + typeof(TModel)); + + var opExpr = junction.Factories.TryCreate(in context); + + if (opExpr is null + && junction.Operator is CriterionOperator.Like + && targetAccess.Type == typeof(string) + && value is string stringValue) + { + opExpr = LikeExpressionGenerator.CreatePatternExpression( + targetAccess, + stringValue, + CriterionDefaults.ResolveWrap(criterion.Wrap), + criterion.Case == CriterionCase.Insensitive); + + if (criterion.Negation) + opExpr = Expression.Not(opExpr); + } + + // ordem dos operandos corrigida: antes o valor ia na posicao do alvo (gerava "valor".Contains(e.Prop) + // e "valor > e.Prop"), invertendo a semantica em relacao ao caminho principal das resolutions. + opExpr ??= ExpressionGenerator.CreateOperatorExpression( + junction.Operator, + criterion.Negation, + valueExpr, + targetAccess, + criterion.Case); predicates.Add(opExpr); } diff --git a/src/RoyalCode.SmartSearch.Linq/Filtering/Resolutions/DisjuctionCriterionResolution.cs b/src/RoyalCode.SmartSearch.Linq/Filtering/Resolutions/DisjunctionCriterionResolution.cs similarity index 94% rename from src/RoyalCode.SmartSearch.Linq/Filtering/Resolutions/DisjuctionCriterionResolution.cs rename to src/RoyalCode.SmartSearch.Linq/Filtering/Resolutions/DisjunctionCriterionResolution.cs index 9e57a7a..e72e72f 100644 --- a/src/RoyalCode.SmartSearch.Linq/Filtering/Resolutions/DisjuctionCriterionResolution.cs +++ b/src/RoyalCode.SmartSearch.Linq/Filtering/Resolutions/DisjunctionCriterionResolution.cs @@ -3,13 +3,13 @@ namespace RoyalCode.SmartSearch.Linq.Filtering.Resolutions; -internal class DisjuctionCriterionResolution : ICriterionResolution +internal class DisjunctionCriterionResolution : ICriterionResolution { private readonly FilterTarget filterTarget; private readonly IReadOnlyList group; private readonly Lack? lack; - public DisjuctionCriterionResolution(FilterTarget filterTarget, IReadOnlyList group) + public DisjunctionCriterionResolution(FilterTarget filterTarget, IReadOnlyList group) { this.filterTarget = filterTarget; this.group = group; diff --git a/src/RoyalCode.SmartSearch.Linq/Filtering/Resolutions/JunctionProperty.cs b/src/RoyalCode.SmartSearch.Linq/Filtering/Resolutions/JunctionProperty.cs index 7c11540..771434b 100644 --- a/src/RoyalCode.SmartSearch.Linq/Filtering/Resolutions/JunctionProperty.cs +++ b/src/RoyalCode.SmartSearch.Linq/Filtering/Resolutions/JunctionProperty.cs @@ -7,10 +7,15 @@ internal class JunctionProperty { private readonly Lack? lack; - public JunctionProperty(PropertySelection property, CriterionAttribute criterion, FilterTarget filterTarget) + public JunctionProperty( + PropertySelection property, + CriterionAttribute criterion, + FilterTarget filterTarget, + CriterionOperatorExpressionFactories factories) { FilterProperty = property; Criterion = criterion; + Factories = factories; var propertySelection = GetPropertySelection(filterTarget); if (propertySelection is null) @@ -31,6 +36,8 @@ public JunctionProperty(PropertySelection property, CriterionAttribute criterion public CriterionAttribute Criterion { get; } + public CriterionOperatorExpressionFactories Factories { get; } + public PropertySelection? ModelPropertySelection { get; } public CriterionOperator Operator { get; } diff --git a/src/RoyalCode.SmartSearch.Linq/SearchesServiceCollectionExtensions.cs b/src/RoyalCode.SmartSearch.Linq/SearchesServiceCollectionExtensions.cs index 22f9f76..5bcafca 100644 --- a/src/RoyalCode.SmartSearch.Linq/SearchesServiceCollectionExtensions.cs +++ b/src/RoyalCode.SmartSearch.Linq/SearchesServiceCollectionExtensions.cs @@ -29,6 +29,10 @@ public static IServiceCollection AddSmartSearchLinq(this IServiceCollection serv services.AddSingleton(OrderByHandlersMap.Instance); services.AddSingleton(SelectorsMap.Instance); + // encapsula as ICriterionOperatorExpressionFactory registradas (ordem de registro, primeira-nao-null-vence) + services.AddSingleton(static sp => new CriterionOperatorExpressionFactories( + sp.GetServices())); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/RoyalCode.SmartSearch.Tests/CriterionCaseTests.cs b/src/RoyalCode.SmartSearch.Tests/CriterionCaseTests.cs new file mode 100644 index 0000000..df3e482 --- /dev/null +++ b/src/RoyalCode.SmartSearch.Tests/CriterionCaseTests.cs @@ -0,0 +1,207 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using RoyalCode.SmartSearch.Linq; +using RoyalCode.SmartSearch.Linq.Services; +using System.Linq.Expressions; + +namespace RoyalCode.SmartSearch.Tests; + +/// +/// Fase 1 do plan-operator-expression-customization: intencao declarativa de case sensitivity +/// (`[Criterion(Case = ...)]`) com fallback portavel via ToUpper() em ambos os lados. +/// +public class CriterionCaseTests +{ + [Theory] + [InlineData(CriterionOperator.Like)] + [InlineData(CriterionOperator.Contains)] + [InlineData(CriterionOperator.StartsWith)] + [InlineData(CriterionOperator.EndsWith)] + public void CreateOperatorExpression_Insensitive_MustNormalizeBothSides_WithToUpper(CriterionOperator @operator) + { + var filter = Expression.Parameter(typeof(string), "f"); + var target = Expression.Parameter(typeof(string), "t"); + + var expression = ExpressionGenerator.CreateOperatorExpression( + @operator, false, filter, target, CriterionCase.Insensitive); + + var text = expression.ToString(); + Assert.Equal(2, text.Split("ToUpper()").Length - 1); + } + + [Theory] + [InlineData(CriterionCase.Default)] + [InlineData(CriterionCase.Sensitive)] + public void CreateOperatorExpression_SensitiveOrDefault_MustNotNormalize(CriterionCase criterionCase) + { + var filter = Expression.Parameter(typeof(string), "f"); + var target = Expression.Parameter(typeof(string), "t"); + + var expression = ExpressionGenerator.CreateOperatorExpression( + CriterionOperator.Contains, false, filter, target, criterionCase); + + Assert.DoesNotContain("ToUpper()", expression.ToString()); + } + + [Fact] + public void CreateOperatorExpression_Insensitive_MustBeIgnored_ForNonStringOperands() + { + var filter = Expression.Parameter(typeof(int), "f"); + var target = Expression.Parameter(typeof(int), "t"); + + var expression = ExpressionGenerator.CreateOperatorExpression( + CriterionOperator.GreaterThanOrEqual, false, filter, target, CriterionCase.Insensitive); + + Assert.DoesNotContain("ToUpper()", expression.ToString()); + } + + [Fact] + public void CreateOperatorExpression_Insensitive_MustBeIgnored_ForEqualOperator() + { + // o plano restringe a normalizacao aos operadores de string Like/Contains/StartsWith/EndsWith + var filter = Expression.Parameter(typeof(string), "f"); + var target = Expression.Parameter(typeof(string), "t"); + + var expression = ExpressionGenerator.CreateOperatorExpression( + CriterionOperator.Equal, false, filter, target, CriterionCase.Insensitive); + + Assert.DoesNotContain("ToUpper()", expression.ToString()); + } + + [Fact] + public void Specifier_ContainsInsensitive_MustMatch_IgnoringCase_InMemory() + { + var specifier = CreateSpecifierFactory().GetSpecifier(); + var query = CcData().AsQueryable(); + + var result = specifier.Specify(query, new CcInsensitiveFilter { Nome = "notebook" }).ToList(); + + var item = Assert.Single(result); + Assert.Equal("Notebook Gamer", item.Nome); + } + + [Fact] + public void Specifier_ContainsDefault_MustNotMatch_DifferentCase_InMemory() + { + // sem declaracao de case, o Contains em memoria e ordinal (case-sensitive) + var specifier = CreateSpecifierFactory().GetSpecifier(); + var query = CcData().AsQueryable(); + + var result = specifier.Specify(query, new CcDefaultFilter { Nome = "notebook" }).ToList(); + + Assert.Empty(result); + } + + [Fact] + public void Disjunction_LikeInsensitive_MustMatch_IgnoringCase_InMemory() + { + var specifier = CreateSpecifierFactory().GetSpecifier(); + var query = new List + { + new() { Id = 1, Nome = "Carlos", Apelido = "Kadu" }, + new() { Id = 2, Nome = "Bruno", Apelido = "Bl" }, + }.AsQueryable(); + + var result = specifier.Specify(query, new CcJunctionFilter { NomeOrApelido = "KADU" }).ToList(); + + var item = Assert.Single(result); + Assert.Equal(1, item.Id); + } + + [Fact] + public async Task Search_ContainsInsensitive_MustMatch_OnSqlite() + { + var provider = await CreateSqliteProvider(); + using var scope = provider.CreateScope(); + var criteria = scope.ServiceProvider.GetRequiredService>(); + + var result = await criteria + .FilterBy(new CcEntityInsensitiveFilter { Nome = "nOtEbOoK" }) + .CollectAsync(); + + var item = Assert.Single(result); + Assert.Equal("Notebook Gamer", item.Nome); + } + + private static ISpecifierFactory CreateSpecifierFactory() + { + ServiceCollection services = new(); + services.AddSmartSearchLinq(); + return services.BuildServiceProvider().GetRequiredService(); + } + + private static List CcData() => + [ + new() { Id = 1, Nome = "Notebook Gamer" }, + new() { Id = 2, Nome = "Mouse" }, + ]; + + private static async Task CreateSqliteProvider() + { + ServiceCollection services = new(); + var sqlite = new Microsoft.Data.Sqlite.SqliteConnection("DataSource=:memory:"); + await sqlite.OpenAsync(); + services.AddSingleton(sqlite); + services.AddDbContext(b => b.UseSqlite(sqlite)); + services.AddEntityFrameworkSearches(s => s.Add()); + var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.EnsureCreatedAsync(); + db.AddRange( + new CcEntity { Id = 1, Nome = "Notebook Gamer" }, + new CcEntity { Id = 2, Nome = "Mouse" }); + await db.SaveChangesAsync(); + return provider; + } +} + +file class CcDbContext : DbContext +{ + public CcDbContext(DbContextOptions options) : base(options) { } + + protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.Entity(); +} + +public class CcModel +{ + public int Id { get; set; } + public string Nome { get; set; } = null!; +} + +public class CcEntity +{ + public int Id { get; set; } + public string Nome { get; set; } = null!; +} + +public class CcPessoa +{ + public int Id { get; set; } + public string Nome { get; set; } = null!; + public string Apelido { get; set; } = null!; +} + +public class CcInsensitiveFilter +{ + [Criterion(CriterionOperator.Contains, Case = CriterionCase.Insensitive)] + public string? Nome { get; set; } +} + +public class CcDefaultFilter +{ + [Criterion(CriterionOperator.Contains)] + public string? Nome { get; set; } +} + +public class CcEntityInsensitiveFilter +{ + [Criterion(CriterionOperator.Contains, Case = CriterionCase.Insensitive)] + public string? Nome { get; set; } +} + +public class CcJunctionFilter +{ + [Criterion(Case = CriterionCase.Insensitive)] // operador Auto -> Like (default), via disjuncao Nome/Apelido + public string? NomeOrApelido { get; set; } +} diff --git a/src/RoyalCode.SmartSearch.Tests/CriterionOperatorFactoriesTests.cs b/src/RoyalCode.SmartSearch.Tests/CriterionOperatorFactoriesTests.cs new file mode 100644 index 0000000..666602b --- /dev/null +++ b/src/RoyalCode.SmartSearch.Tests/CriterionOperatorFactoriesTests.cs @@ -0,0 +1,132 @@ +using Microsoft.Extensions.DependencyInjection; +using RoyalCode.SmartSearch.Linq; +using RoyalCode.SmartSearch.Linq.Filtering; +using RoyalCode.SmartSearch.Linq.Services; +using System.Linq.Expressions; + +namespace RoyalCode.SmartSearch.Tests; + +/// +/// Fase 2 do plan-operator-expression-customization: seam de emissao via +/// ICriterionOperatorExpressionFactory (DI, primeira-nao-null-vence pela encapsuladora), +/// alcancando tambem o caminho runtime das disjuncoes. +/// +public class CriterionOperatorFactoriesTests +{ + [Fact] + public void Factory_MustCustomizeOperatorExpression() + { + // intercepta Contains com "false" constante: nenhum registro deve passar pelo filtro + var specifier = CreateSpecifierFactory(new OperatorInterceptor(CriterionOperator.Contains, static () => Expression.Constant(false))) + .GetSpecifier(); + + var query = new List { new() { Id = 1, Nome = "abc" } }.AsQueryable(); + var result = specifier.Specify(query, new FacFilterA { Nome = "a" }).ToList(); + + Assert.Empty(result); + } + + [Fact] + public void Factory_ReturningNull_MustFallBackToDefaultEmission() + { + var specifier = CreateSpecifierFactory(new OperatorInterceptor(CriterionOperator.StartsWith, static () => Expression.Constant(false))) + .GetSpecifier(); + + var query = new List { new() { Id = 1, Nome = "abc" }, new() { Id = 2, Nome = "zzz" } }.AsQueryable(); + var result = specifier.Specify(query, new FacFilterB { Nome = "a" }).ToList(); + + // a factory intercepta outro operador (StartsWith): o Contains cai na emissao default e filtra normalmente + var item = Assert.Single(result); + Assert.Equal(1, item.Id); + } + + [Fact] + public void Factories_MustBeTried_InRegistrationOrder_FirstNonNullWins() + { + var alwaysFalse = new OperatorInterceptor(CriterionOperator.Contains, static () => Expression.Constant(false)); + var alwaysTrue = new OperatorInterceptor(CriterionOperator.Contains, static () => Expression.Constant(true)); + + // false primeiro: nada passa + var specifier = CreateSpecifierFactory(alwaysFalse, alwaysTrue).GetSpecifier(); + var query = new List { new() { Id = 1, Nome = "abc" }, new() { Id = 2, Nome = "zzz" } }.AsQueryable(); + Assert.Empty(specifier.Specify(query, new FacFilterC { Nome = "a" }).ToList()); + + // true primeiro (tipos distintos por causa do cache global de specifiers): tudo passa + var specifier2 = CreateSpecifierFactory(alwaysTrue, alwaysFalse).GetSpecifier(); + var query2 = new List { new() { Id = 1, Nome = "abc" }, new() { Id = 2, Nome = "zzz" } }.AsQueryable(); + Assert.Equal(2, specifier2.Specify(query2, new FacFilterD { Nome = "a" }).ToList().Count); + } + + [Fact] + public void DisjunctionRuntimePath_MustUseFactory() + { + // a disjuncao ("NomeOrApelido") monta a expressao em runtime: a factory deve valer la tambem + var specifier = CreateSpecifierFactory(new OperatorInterceptor(CriterionOperator.Contains, static () => Expression.Constant(false))) + .GetSpecifier(); + + var query = new List { new() { Id = 1, Nome = "abc", Apelido = "zzz" } }.AsQueryable(); + var result = specifier.Specify(query, new FacFilterE { NomeOrApelido = "a" }).ToList(); + + Assert.Empty(result); + } + + private static ISpecifierFactory CreateSpecifierFactory(params ICriterionOperatorExpressionFactory[] factories) + { + ServiceCollection services = new(); + services.AddSmartSearchLinq(); + foreach (var factory in factories) + services.AddSingleton(factory); + return services.BuildServiceProvider().GetRequiredService(); + } + + private sealed class OperatorInterceptor : ICriterionOperatorExpressionFactory + { + private readonly CriterionOperator @operator; + private readonly Func create; + + public OperatorInterceptor(CriterionOperator @operator, Func create) + { + this.@operator = @operator; + this.create = create; + } + + public Expression? TryCreate(in CriterionOperatorContext context) + => context.Operator == @operator ? create() : null; + } +} + +public class FacModelA { public int Id { get; set; } public string Nome { get; set; } = null!; } +public class FacModelB { public int Id { get; set; } public string Nome { get; set; } = null!; } +public class FacModelC { public int Id { get; set; } public string Nome { get; set; } = null!; } +public class FacModelD { public int Id { get; set; } public string Nome { get; set; } = null!; } +public class FacModelE { public int Id { get; set; } public string Nome { get; set; } = null!; public string Apelido { get; set; } = null!; } + +public class FacFilterA +{ + [Criterion(CriterionOperator.Contains)] + public string? Nome { get; set; } +} + +public class FacFilterB +{ + [Criterion(CriterionOperator.Contains)] + public string? Nome { get; set; } +} + +public class FacFilterC +{ + [Criterion(CriterionOperator.Contains)] + public string? Nome { get; set; } +} + +public class FacFilterD +{ + [Criterion(CriterionOperator.Contains)] + public string? Nome { get; set; } +} + +public class FacFilterE +{ + [Criterion(CriterionOperator.Contains)] + public string? NomeOrApelido { get; set; } +} diff --git a/src/RoyalCode.SmartSearch.Tests/DisjunctionTests.cs b/src/RoyalCode.SmartSearch.Tests/DisjunctionTests.cs index ae5795b..cbb4cde 100644 --- a/src/RoyalCode.SmartSearch.Tests/DisjunctionTests.cs +++ b/src/RoyalCode.SmartSearch.Tests/DisjunctionTests.cs @@ -117,9 +117,9 @@ public class DisjunctionEntity public class DisjunctionFilter { - [Disjuction("g1")] + [Disjunction("g1")] public string? P1 { get; set; } - [Disjuction("g1")] + [Disjunction("g1")] public string? P2 { get; set; } } diff --git a/src/RoyalCode.SmartSearch.Tests/EntityFrameworkLikeFactoryTests.cs b/src/RoyalCode.SmartSearch.Tests/EntityFrameworkLikeFactoryTests.cs new file mode 100644 index 0000000..ea054cd --- /dev/null +++ b/src/RoyalCode.SmartSearch.Tests/EntityFrameworkLikeFactoryTests.cs @@ -0,0 +1,189 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using RoyalCode.SmartSearch.EntityFramework.Filtering; +using RoyalCode.SmartSearch.Linq.Filtering; +using System.Linq.Expressions; + +namespace RoyalCode.SmartSearch.Tests; + +/// +/// Fase 4 do plan-operator-expression-customization: emissao do Like como EF.Functions.Like +/// (opt-in via AddEntityFrameworkLikeOperator), com paridade de resultados com o helper portavel. +/// +public class EntityFrameworkLikeFactoryTests +{ + [Fact] + public void TryCreate_Like_MustEmit_EfFunctionsLike() + { + var factory = new EntityFrameworkLikeExpressionFactory(); + + var expression = factory.TryCreate(CreateContext(CriterionOperator.Like, CriterionCase.Default)); + + var call = Assert.IsAssignableFrom(expression); + Assert.Equal(nameof(DbFunctionsExtensions.Like), call.Method.Name); + Assert.Equal(typeof(DbFunctionsExtensions), call.Method.DeclaringType); + // wrap default (true): o pattern e concatenado com "%" + Assert.Contains("Concat", call.Arguments[2].ToString()); + } + + [Fact] + public void TryCreate_Insensitive_MustNormalizeBothSides_WithToUpper() + { + var factory = new EntityFrameworkLikeExpressionFactory(); + + var expression = factory.TryCreate(CreateContext(CriterionOperator.Like, CriterionCase.Insensitive)); + + Assert.NotNull(expression); + Assert.Equal(2, expression.ToString().Split("ToUpper()").Length - 1); + } + + [Fact] + public void TryCreate_WrapNone_MustUsePatternAsIs() + { + var factory = new EntityFrameworkLikeExpressionFactory(); + + var expression = factory.TryCreate(CreateContext(CriterionOperator.Like, CriterionCase.Default, LikeWrap.None)); + + var call = Assert.IsAssignableFrom(expression); + Assert.DoesNotContain("Concat", call.Arguments[2].ToString()); + } + + [Fact] + public void TryCreate_Negation_MustWrapWithNot() + { + var factory = new EntityFrameworkLikeExpressionFactory(); + + var expression = factory.TryCreate(CreateContext(CriterionOperator.Like, CriterionCase.Default, negation: true)); + + Assert.Equal(ExpressionType.Not, Assert.IsAssignableFrom(expression).NodeType); + } + + [Theory] + [InlineData(CriterionOperator.Contains)] + [InlineData(CriterionOperator.StartsWith)] + [InlineData(CriterionOperator.Equal)] + public void TryCreate_OutrosOperadores_MustReturnNull(CriterionOperator @operator) + { + var factory = new EntityFrameworkLikeExpressionFactory(); + + Assert.Null(factory.TryCreate(CreateContext(@operator, CriterionCase.Default))); + } + + [Fact] + public void TryCreate_OperandosNaoString_MustReturnNull() + { + var factory = new EntityFrameworkLikeExpressionFactory(); + var filter = Expression.Parameter(typeof(int), "f"); + var target = Expression.Parameter(typeof(int), "t"); + var context = new CriterionOperatorContext( + CriterionOperator.Like, CriterionCase.Default, LikeWrap.Default, false, + filter, target, null, typeof(object)); + + Assert.Null(factory.TryCreate(context)); + } + + // --- paridade com o helper portavel (mesmos casos da LikeSearchTests, com a factory registrada) --- + + [Fact] + public async Task Search_ComEfLike_TemParidadeComOHelperPortavel() + { + var provider = await CreateProvider(); + using var scope = provider.CreateScope(); + + // ICriteria e fluente/stateful: uma instancia nova por consulta + ICriteria Criteria() => scope.ServiceProvider.GetRequiredService>(); + + // curinga do usuario honrado + var curinga = await Criteria().FilterBy(new EflFiltroAuto { Nome = "Jo%o" }).CollectAsync(); + Assert.Equal(2, curinga.Count); + Assert.Contains(curinga, p => p.Nome == "Joao"); + Assert.Contains(curinga, p => p.Nome == "Jono"); + + // "100%" via Like honra o curinga (2); via Contains e literal (1) — Contains nao passa pela factory + var like = await Criteria().FilterBy(new EflFiltroAuto { Nome = "100%" }).CollectAsync(); + Assert.Equal(2, like.Count); + + var contains = await Criteria().FilterBy(new EflFiltroContains { Nome = "100%" }).CollectAsync(); + Assert.Single(contains); + + // sem wrap e sem curinga: match exato + var exato = await Criteria().FilterBy(new EflFiltroSemWrap { Nome = "oao" }).CollectAsync(); + Assert.Empty(exato); + + // insensitive: UPPER(...) LIKE UPPER(...) + var insensitive = await Criteria().FilterBy(new EflFiltroInsensitive { Nome = "jOnO" }).CollectAsync(); + var item = Assert.Single(insensitive); + Assert.Equal("Jono", item.Nome); + } + + private static CriterionOperatorContext CreateContext( + CriterionOperator @operator, + CriterionCase @case, + LikeWrap wrap = LikeWrap.Default, + bool negation = false) + { + var filter = Expression.Parameter(typeof(string), "f"); + var target = Expression.Parameter(typeof(string), "t"); + return new CriterionOperatorContext(@operator, @case, wrap, negation, filter, target, null, typeof(object)); + } + + private static async Task CreateProvider() + { + ServiceCollection services = new(); + var sqlite = new Microsoft.Data.Sqlite.SqliteConnection("DataSource=:memory:"); + await sqlite.OpenAsync(); + services.AddSingleton(sqlite); + services.AddDbContext(b => b.UseSqlite(sqlite)); + services.AddEntityFrameworkSearches(s => s.Add()); + services.AddEntityFrameworkLikeOperator(); + var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.EnsureCreatedAsync(); + db.AddRange( + new EflProduto { Id = 1, Nome = "Joao" }, + new EflProduto { Id = 2, Nome = "Jono" }, + new EflProduto { Id = 3, Nome = "Joa" }, + new EflProduto { Id = 4, Nome = "100% algodao" }, + new EflProduto { Id = 5, Nome = "100 metros" }); + await db.SaveChangesAsync(); + return provider; + } +} + +file class EflDbContext : DbContext +{ + public EflDbContext(DbContextOptions options) : base(options) { } + + protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.Entity(); +} + +public class EflProduto +{ + public int Id { get; set; } + public string Nome { get; set; } = null!; +} + +public class EflFiltroAuto +{ + [Criterion] + public string? Nome { get; set; } +} + +public class EflFiltroContains +{ + [Criterion(CriterionOperator.Contains)] + public string? Nome { get; set; } +} + +public class EflFiltroSemWrap +{ + [Criterion(Wrap = LikeWrap.None)] + public string? Nome { get; set; } +} + +public class EflFiltroInsensitive +{ + [Criterion(Case = CriterionCase.Insensitive)] + public string? Nome { get; set; } +} diff --git a/src/RoyalCode.SmartSearch.Tests/LikePatternExpressionTests.cs b/src/RoyalCode.SmartSearch.Tests/LikePatternExpressionTests.cs new file mode 100644 index 0000000..1d3889a --- /dev/null +++ b/src/RoyalCode.SmartSearch.Tests/LikePatternExpressionTests.cs @@ -0,0 +1,119 @@ +using RoyalCode.SmartSearch.Linq.Filtering; +using System.Linq.Expressions; + +namespace RoyalCode.SmartSearch.Tests; + +/// +/// Fase 3 do plan-operator-expression-customization: casamento guloso do Like portavel +/// (ancoras StartsWith/EndsWith, segmentos em ordem via fatiamento, corte de fatiamentos). +/// A mesma arvore e traduzivel pelos providers e executavel em memoria — aqui, executada em memoria. +/// +public class LikePatternExpressionTests +{ + [Theory] + // ancoras nas duas pontas: "Jo%o" = comeca com "Jo" e termina com "o" (sem sobreposicao) + [InlineData("Joao", "Jo%o", true)] + [InlineData("Jono", "Jo%o", true)] + [InlineData("Joa", "Jo%o", false)] + [InlineData("Jo", "Jo%o", false)] // o "o" final nao pode sobrepor o prefixo "Jo" + // ordem dos segmentos do meio: "%b%a%" exige "b" antes de "a" + [InlineData("xbya", "%b%a%", true)] + [InlineData("ab", "%b%a%", false)] + // segmento repetido: "a%a" exige duas ocorrencias + [InlineData("aa", "a%a", true)] + [InlineData("aba", "a%a", true)] + [InlineData("a", "a%a", false)] + // ancora simples + [InlineData("abcd", "abc%", true)] + [InlineData("zabc", "abc%", false)] + [InlineData("zabc", "%abc", true)] + [InlineData("abcz", "%abc", false)] + // sem curinga, sem wrap: igualdade exata (semantica do LIKE) + [InlineData("abc", "abc", true)] + [InlineData("zabcz", "abc", false)] + public void Match_SemWrap(string candidate, string pattern, bool expected) + { + Assert.Equal(expected, Match(candidate, pattern, wrap: false)); + } + + [Theory] + // com wrap, o valor flutua como substring; curingas internos continuam honrados + [InlineData("zabcz", "abc", true)] + [InlineData("zaXbcz", "abc", false)] + [InlineData("xx100% de algodao", "100%", true)] + [InlineData("Joao", "Jo%o", true)] + [InlineData("Joa", "Jo%o", false)] + public void Match_ComWrap(string candidate, string pattern, bool expected) + { + Assert.Equal(expected, Match(candidate, pattern, wrap: true)); + } + + [Theory] + [InlineData("JOAO", "joao", true)] + [InlineData("Joao", "jO%o", true)] + [InlineData("Joao", "z%o", false)] + public void Match_Insensitive(string candidate, string pattern, bool expected) + { + Assert.Equal(expected, Match(candidate, pattern, wrap: true, ignoreCase: true)); + } + + [Fact] + public void Match_ComCorteDeFatiamentos_ContinuaFuncional() + { + // mais segmentos que o corte (MaxSliceOperations = 5): os excedentes degradam para Contains + Assert.True(Match("abcdefgh", "%a%b%c%d%e%f%g%h%", wrap: false)); + Assert.False(Match("abcdefg", "%a%b%c%d%e%f%g%h%", wrap: false)); + // ordem garantida ate o corte: "b" antes de "a" nos primeiros segmentos nao da match + Assert.False(Match("bacdefgh", "%a%b%c%d%e%f%g%h%", wrap: false)); + } + + [Fact] + public void Match_PatternSoDeCuringas_DaMatchEmTudo() + { + Assert.True(Match("qualquer", "%", wrap: false)); + Assert.True(Match("", "%%", wrap: false)); + } + + [Fact] + public void Apply_ComNegacao_InverteOResultado() + { + var query = new List + { + new() { Id = 1, Nome = "Joao" }, + new() { Id = 2, Nome = "Maria" }, + }.AsQueryable(); + + var result = LikeExpressionGenerator.Apply( + query, "Jo%o", p => p.Nome, wrap: false, ignoreCase: false, negation: true) + .ToList(); + + var item = Assert.Single(result); + Assert.Equal(2, item.Id); + } + + [Fact] + public void Apply_ComValorVazio_NaoFiltra() + { + var query = new List { new() { Id = 1, Nome = "Joao" } }.AsQueryable(); + + var result = LikeExpressionGenerator.Apply( + query, "", p => p.Nome, wrap: true, ignoreCase: false, negation: false) + .ToList(); + + Assert.Single(result); + } + + private static bool Match(string candidate, string pattern, bool wrap, bool ignoreCase = false) + { + var parameter = Expression.Parameter(typeof(string), "s"); + var expression = LikeExpressionGenerator.CreatePatternExpression(parameter, pattern, wrap, ignoreCase); + var predicate = Expression.Lambda>(expression, parameter).Compile(); + return predicate(candidate); + } +} + +public class LkPessoa +{ + public int Id { get; set; } + public string Nome { get; set; } = null!; +} diff --git a/src/RoyalCode.SmartSearch.Tests/LikeSearchTests.cs b/src/RoyalCode.SmartSearch.Tests/LikeSearchTests.cs new file mode 100644 index 0000000..05abcd6 --- /dev/null +++ b/src/RoyalCode.SmartSearch.Tests/LikeSearchTests.cs @@ -0,0 +1,188 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using RoyalCode.SmartSearch.Linq; +using RoyalCode.SmartSearch.Linq.Services; + +namespace RoyalCode.SmartSearch.Tests; + +/// +/// Fase 3 do plan-operator-expression-customization: semantica de Like (curingas honrados) +/// vs Contains (substring literal) de ponta a ponta no SQLite, com o helper portavel +/// (as pecas Contains/StartsWith/EndsWith/IndexOf/Substring sao traduzidas pelo provider). +/// +public class LikeSearchTests +{ + [Fact] + public async Task Like_ComCuringaDoUsuario_HonraOPattern() + { + var provider = await CreateProvider(); + using var scope = provider.CreateScope(); + var criteria = scope.ServiceProvider.GetRequiredService>(); + + var result = await criteria.FilterBy(new LkFiltroAuto { Nome = "Jo%o" }).CollectAsync(); + + Assert.Equal(2, result.Count); + Assert.Contains(result, p => p.Nome == "Joao"); + Assert.Contains(result, p => p.Nome == "Jono"); + } + + [Fact] + public async Task Like_EContains_DivergemParaPercentLiteral() + { + var provider = await CreateProvider(); + using var scope = provider.CreateScope(); + + // ICriteria e fluente/stateful: uma instancia nova por consulta + ICriteria Criteria() => scope.ServiceProvider.GetRequiredService>(); + + // mesmo valor "100%": Like honra o curinga (2 matches), Contains trata literal (1 match) + var like = await Criteria().FilterBy(new LkFiltroAuto { Nome = "100%" }).CollectAsync(); + Assert.Equal(2, like.Count); + + var contains = await Criteria().FilterBy(new LkFiltroContains { Nome = "100%" }).CollectAsync(); + var item = Assert.Single(contains); + Assert.Equal("100% algodao", item.Nome); + } + + [Fact] + public async Task Like_SemWrap_ExigeMatchExato_QuandoNaoHaCuringa() + { + var provider = await CreateProvider(); + using var scope = provider.CreateScope(); + + // ICriteria e fluente/stateful: uma instancia nova por consulta + ICriteria Criteria() => scope.ServiceProvider.GetRequiredService>(); + + // "oao" sem wrap: igualdade exata -> nada; com wrap (default) seria substring -> "Joao" + var exato = await Criteria().FilterBy(new LkFiltroSemWrap { Nome = "oao" }).CollectAsync(); + Assert.Empty(exato); + + var comWrap = await Criteria().FilterBy(new LkFiltroAuto { Nome = "oao" }).CollectAsync(); + var item = Assert.Single(comWrap); + Assert.Equal("Joao", item.Nome); + } + + [Fact] + public async Task Like_Insensitive_MustMatch_IgnoringCase() + { + var provider = await CreateProvider(); + using var scope = provider.CreateScope(); + var criteria = scope.ServiceProvider.GetRequiredService>(); + + var result = await criteria.FilterBy(new LkFiltroInsensitive { Nome = "jOnO" }).CollectAsync(); + + var item = Assert.Single(result); + Assert.Equal("Jono", item.Nome); + } + + private static async Task CreateProvider() + { + ServiceCollection services = new(); + var sqlite = new Microsoft.Data.Sqlite.SqliteConnection("DataSource=:memory:"); + await sqlite.OpenAsync(); + services.AddSingleton(sqlite); + services.AddDbContext(b => b.UseSqlite(sqlite)); + services.AddEntityFrameworkSearches(s => s.Add()); + var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.EnsureCreatedAsync(); + db.AddRange( + new LkProduto { Id = 1, Nome = "Joao" }, + new LkProduto { Id = 2, Nome = "Jono" }, + new LkProduto { Id = 3, Nome = "Joa" }, + new LkProduto { Id = 4, Nome = "100% algodao" }, + new LkProduto { Id = 5, Nome = "100 metros" }); + await db.SaveChangesAsync(); + return provider; + } +} + +/// +/// Fase 3: a valvula de escape — trocar o operador default de string para Contains restaura +/// o comportamento literal por completo. Classe separada com tipos proprios (cache global de specifiers). +/// +public class LikeDefaultStringOperatorTests +{ + [Fact] + public void DefaultStringOperator_Contains_RestauraComportamentoLiteral() + { + ServiceCollection services = new(); + services.AddSmartSearchLinq(); + var specifierFactory = services.BuildServiceProvider().GetRequiredService(); + + var original = CriterionDefaults.DefaultStringOperator; + try + { + CriterionDefaults.DefaultStringOperator = CriterionOperator.Contains; + + // a geracao ocorre aqui (tipos exclusivos deste teste), lendo o default trocado + var specifier = specifierFactory.GetSpecifier(); + + var query = new List + { + new() { Id = 1, Nome = "50% off" }, + new() { Id = 2, Nome = "5000 off" }, + }.AsQueryable(); + + // com Like (default da lib), "50%" honraria o curinga e daria 2 matches; literal da 1 + var result = specifier.Specify(query, new LkSwapFilter { Nome = "50%" }).ToList(); + + var item = Assert.Single(result); + Assert.Equal(1, item.Id); + } + finally + { + CriterionDefaults.DefaultStringOperator = original; + } + } +} + +file class LkDbContext : DbContext +{ + public LkDbContext(DbContextOptions options) : base(options) { } + + protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.Entity(); +} + +public class LkProduto +{ + public int Id { get; set; } + public string Nome { get; set; } = null!; +} + +public class LkSwapModel +{ + public int Id { get; set; } + public string Nome { get; set; } = null!; +} + +public class LkFiltroAuto +{ + [Criterion] // Auto -> Like (default): curingas honrados, wrap default + public string? Nome { get; set; } +} + +public class LkFiltroSemWrap +{ + [Criterion(Wrap = LikeWrap.None)] + public string? Nome { get; set; } +} + +public class LkFiltroContains +{ + [Criterion(CriterionOperator.Contains)] + public string? Nome { get; set; } +} + +public class LkFiltroInsensitive +{ + [Criterion(Case = CriterionCase.Insensitive)] + public string? Nome { get; set; } +} + +public class LkSwapFilter +{ + [Criterion] // Auto: le CriterionDefaults.DefaultStringOperator na geracao + public string? Nome { get; set; } +} diff --git a/src/RoyalCode.SmartSearch.Tests/NpgsqlILikeFactoryTests.cs b/src/RoyalCode.SmartSearch.Tests/NpgsqlILikeFactoryTests.cs new file mode 100644 index 0000000..5c217db --- /dev/null +++ b/src/RoyalCode.SmartSearch.Tests/NpgsqlILikeFactoryTests.cs @@ -0,0 +1,89 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using RoyalCode.SmartSearch.EntityFramework.Npgsql.Filtering; +using RoyalCode.SmartSearch.Linq; +using RoyalCode.SmartSearch.Linq.Filtering; +using System.Linq.Expressions; + +namespace RoyalCode.SmartSearch.Tests; + +/// +/// Fase 5 do plan-operator-expression-customization: pacote Npgsql com ILIKE para Like + Insensitive. +/// Sem PostgreSQL no repo (Questao 4), a verificacao e por assercao da arvore gerada. +/// +public class NpgsqlILikeFactoryTests +{ + [Fact] + public void TryCreate_LikeInsensitive_MustEmit_EfFunctionsILike() + { + var factory = new NpgsqlILikeExpressionFactory(); + + var expression = factory.TryCreate(CreateContext(CriterionOperator.Like, CriterionCase.Insensitive)); + + var call = Assert.IsAssignableFrom(expression); + Assert.Equal("ILike", call.Method.Name); + Assert.Equal(typeof(NpgsqlDbFunctionsExtensions), call.Method.DeclaringType); + // wrap default (true): o pattern e concatenado com "%" + Assert.Contains("Concat", call.Arguments[2].ToString()); + } + + [Fact] + public void TryCreate_WrapNone_MustUsePatternAsIs() + { + var factory = new NpgsqlILikeExpressionFactory(); + + var expression = factory.TryCreate(CreateContext(CriterionOperator.Like, CriterionCase.Insensitive, LikeWrap.None)); + + var call = Assert.IsAssignableFrom(expression); + Assert.DoesNotContain("Concat", call.Arguments[2].ToString()); + } + + [Fact] + public void TryCreate_Negation_MustWrapWithNot() + { + var factory = new NpgsqlILikeExpressionFactory(); + + var expression = factory.TryCreate(CreateContext(CriterionOperator.Like, CriterionCase.Insensitive, negation: true)); + + Assert.Equal(ExpressionType.Not, Assert.IsAssignableFrom(expression).NodeType); + } + + [Theory] + [InlineData(CriterionOperator.Like, CriterionCase.Default)] + [InlineData(CriterionOperator.Like, CriterionCase.Sensitive)] + [InlineData(CriterionOperator.Contains, CriterionCase.Insensitive)] + public void TryCreate_ForaDoEscopo_MustReturnNull(CriterionOperator @operator, CriterionCase @case) + { + var factory = new NpgsqlILikeExpressionFactory(); + + Assert.Null(factory.TryCreate(CreateContext(@operator, @case))); + } + + [Fact] + public void AddNpgsqlLikeOperators_MustRegister_ILikeBeforeEfLike() + { + ServiceCollection services = new(); + services.AddSmartSearchLinq(); + services.AddNpgsqlLikeOperators(); + var factories = services.BuildServiceProvider().GetRequiredService(); + + // Insensitive -> ILIKE (a factory Npgsql vence por ordem de registro) + var insensitive = factories.TryCreate(CreateContext(CriterionOperator.Like, CriterionCase.Insensitive)); + Assert.Equal("ILike", Assert.IsAssignableFrom(insensitive).Method.Name); + + // demais casos de Like -> EF.Functions.Like + var @default = factories.TryCreate(CreateContext(CriterionOperator.Like, CriterionCase.Default)); + Assert.Equal(nameof(DbFunctionsExtensions.Like), Assert.IsAssignableFrom(@default).Method.Name); + } + + private static CriterionOperatorContext CreateContext( + CriterionOperator @operator, + CriterionCase @case, + LikeWrap wrap = LikeWrap.Default, + bool negation = false) + { + var filter = Expression.Parameter(typeof(string), "f"); + var target = Expression.Parameter(typeof(string), "t"); + return new CriterionOperatorContext(@operator, @case, wrap, negation, filter, target, null, typeof(object)); + } +} diff --git a/src/RoyalCode.SmartSearch.Tests/RoyalCode.SmartSearch.Tests.csproj b/src/RoyalCode.SmartSearch.Tests/RoyalCode.SmartSearch.Tests.csproj index 1ed2bed..6a13fd1 100644 --- a/src/RoyalCode.SmartSearch.Tests/RoyalCode.SmartSearch.Tests.csproj +++ b/src/RoyalCode.SmartSearch.Tests/RoyalCode.SmartSearch.Tests.csproj @@ -14,6 +14,7 @@ + diff --git a/src/SmartSearch.sln b/src/SmartSearch.sln index 64e1a0a..091283c 100644 --- a/src/SmartSearch.sln +++ b/src/SmartSearch.sln @@ -31,47 +31,144 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RoyalCode.SmartSearch.AspNe EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{789D2113-F8D2-401F-87CB-4CB728AA2674}" ProjectSection(SolutionItems) = preProject - ..\docs\archtecture.md = ..\docs\archtecture.md - ..\docs\problems.md = ..\docs\problems.md - ..\docs\search.md = ..\docs\search.md - ..\docs\validations.md = ..\docs\validations.md + .docs\archtecture.md = .docs\archtecture.md + .ai\references\problems\problems.md = .ai\references\problems\problems.md + .docs\smartsearch.md = .docs\smartsearch.md + .ai\references\problems\validations.md = .ai\references\problems\validations.md EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "plans", "plans", "{53A6F32B-CA1F-4521-BE77-3AA5031B438D}" ProjectSection(SolutionItems) = preProject - .docs\plans\plan-operator-expression-customization.md = .docs\plans\plan-operator-expression-customization.md + .ai\plans\plan-api-typos-e-documentacao.md = .ai\plans\plan-api-typos-e-documentacao.md + .ai\plans\plan-smartsearch-demo.md = .ai\plans\plan-smartsearch-demo.md EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RoyalCode.SmartSearch.EntityFramework.Npgsql", "RoyalCode.SmartSearch.EntityFramework.Npgsql\RoyalCode.SmartSearch.EntityFramework.Npgsql.csproj", "{97EE14CB-2173-4889-8F2E-6C73B8C9F744}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Samples", "Samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RoyalCode.SmartSearch.Demo", "RoyalCode.SmartSearch.Demo\RoyalCode.SmartSearch.Demo.csproj", "{71FC0F29-D903-4B5F-8918-FAB892F87147}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RoyalCode.SmartSearch.Demo.Tests", "RoyalCode.SmartSearch.Demo.Tests\RoyalCode.SmartSearch.Demo.Tests.csproj", "{BC6DECBB-ECF1-44ED-86B5-B7BE12D5998E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {CF945035-3E2C-4D16-AD95-B41D422A42B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {CF945035-3E2C-4D16-AD95-B41D422A42B5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CF945035-3E2C-4D16-AD95-B41D422A42B5}.Debug|x64.ActiveCfg = Debug|Any CPU + {CF945035-3E2C-4D16-AD95-B41D422A42B5}.Debug|x64.Build.0 = Debug|Any CPU + {CF945035-3E2C-4D16-AD95-B41D422A42B5}.Debug|x86.ActiveCfg = Debug|Any CPU + {CF945035-3E2C-4D16-AD95-B41D422A42B5}.Debug|x86.Build.0 = Debug|Any CPU {CF945035-3E2C-4D16-AD95-B41D422A42B5}.Release|Any CPU.ActiveCfg = Release|Any CPU {CF945035-3E2C-4D16-AD95-B41D422A42B5}.Release|Any CPU.Build.0 = Release|Any CPU + {CF945035-3E2C-4D16-AD95-B41D422A42B5}.Release|x64.ActiveCfg = Release|Any CPU + {CF945035-3E2C-4D16-AD95-B41D422A42B5}.Release|x64.Build.0 = Release|Any CPU + {CF945035-3E2C-4D16-AD95-B41D422A42B5}.Release|x86.ActiveCfg = Release|Any CPU + {CF945035-3E2C-4D16-AD95-B41D422A42B5}.Release|x86.Build.0 = Release|Any CPU {DBEBDD2C-37A5-4D17-9DB9-1ACD67749E13}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {DBEBDD2C-37A5-4D17-9DB9-1ACD67749E13}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DBEBDD2C-37A5-4D17-9DB9-1ACD67749E13}.Debug|x64.ActiveCfg = Debug|Any CPU + {DBEBDD2C-37A5-4D17-9DB9-1ACD67749E13}.Debug|x64.Build.0 = Debug|Any CPU + {DBEBDD2C-37A5-4D17-9DB9-1ACD67749E13}.Debug|x86.ActiveCfg = Debug|Any CPU + {DBEBDD2C-37A5-4D17-9DB9-1ACD67749E13}.Debug|x86.Build.0 = Debug|Any CPU {DBEBDD2C-37A5-4D17-9DB9-1ACD67749E13}.Release|Any CPU.ActiveCfg = Release|Any CPU {DBEBDD2C-37A5-4D17-9DB9-1ACD67749E13}.Release|Any CPU.Build.0 = Release|Any CPU + {DBEBDD2C-37A5-4D17-9DB9-1ACD67749E13}.Release|x64.ActiveCfg = Release|Any CPU + {DBEBDD2C-37A5-4D17-9DB9-1ACD67749E13}.Release|x64.Build.0 = Release|Any CPU + {DBEBDD2C-37A5-4D17-9DB9-1ACD67749E13}.Release|x86.ActiveCfg = Release|Any CPU + {DBEBDD2C-37A5-4D17-9DB9-1ACD67749E13}.Release|x86.Build.0 = Release|Any CPU {DDAB919D-3519-4208-A2B1-DCE122228400}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {DDAB919D-3519-4208-A2B1-DCE122228400}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DDAB919D-3519-4208-A2B1-DCE122228400}.Debug|x64.ActiveCfg = Debug|Any CPU + {DDAB919D-3519-4208-A2B1-DCE122228400}.Debug|x64.Build.0 = Debug|Any CPU + {DDAB919D-3519-4208-A2B1-DCE122228400}.Debug|x86.ActiveCfg = Debug|Any CPU + {DDAB919D-3519-4208-A2B1-DCE122228400}.Debug|x86.Build.0 = Debug|Any CPU {DDAB919D-3519-4208-A2B1-DCE122228400}.Release|Any CPU.ActiveCfg = Release|Any CPU {DDAB919D-3519-4208-A2B1-DCE122228400}.Release|Any CPU.Build.0 = Release|Any CPU + {DDAB919D-3519-4208-A2B1-DCE122228400}.Release|x64.ActiveCfg = Release|Any CPU + {DDAB919D-3519-4208-A2B1-DCE122228400}.Release|x64.Build.0 = Release|Any CPU + {DDAB919D-3519-4208-A2B1-DCE122228400}.Release|x86.ActiveCfg = Release|Any CPU + {DDAB919D-3519-4208-A2B1-DCE122228400}.Release|x86.Build.0 = Release|Any CPU {DEAF76E8-950F-4D38-B98B-9099C9F190ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {DEAF76E8-950F-4D38-B98B-9099C9F190ED}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DEAF76E8-950F-4D38-B98B-9099C9F190ED}.Debug|x64.ActiveCfg = Debug|Any CPU + {DEAF76E8-950F-4D38-B98B-9099C9F190ED}.Debug|x64.Build.0 = Debug|Any CPU + {DEAF76E8-950F-4D38-B98B-9099C9F190ED}.Debug|x86.ActiveCfg = Debug|Any CPU + {DEAF76E8-950F-4D38-B98B-9099C9F190ED}.Debug|x86.Build.0 = Debug|Any CPU {DEAF76E8-950F-4D38-B98B-9099C9F190ED}.Release|Any CPU.ActiveCfg = Release|Any CPU {DEAF76E8-950F-4D38-B98B-9099C9F190ED}.Release|Any CPU.Build.0 = Release|Any CPU + {DEAF76E8-950F-4D38-B98B-9099C9F190ED}.Release|x64.ActiveCfg = Release|Any CPU + {DEAF76E8-950F-4D38-B98B-9099C9F190ED}.Release|x64.Build.0 = Release|Any CPU + {DEAF76E8-950F-4D38-B98B-9099C9F190ED}.Release|x86.ActiveCfg = Release|Any CPU + {DEAF76E8-950F-4D38-B98B-9099C9F190ED}.Release|x86.Build.0 = Release|Any CPU {92A0A603-CFF6-498E-BED8-8D0D21561AC2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {92A0A603-CFF6-498E-BED8-8D0D21561AC2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {92A0A603-CFF6-498E-BED8-8D0D21561AC2}.Debug|x64.ActiveCfg = Debug|Any CPU + {92A0A603-CFF6-498E-BED8-8D0D21561AC2}.Debug|x64.Build.0 = Debug|Any CPU + {92A0A603-CFF6-498E-BED8-8D0D21561AC2}.Debug|x86.ActiveCfg = Debug|Any CPU + {92A0A603-CFF6-498E-BED8-8D0D21561AC2}.Debug|x86.Build.0 = Debug|Any CPU {92A0A603-CFF6-498E-BED8-8D0D21561AC2}.Release|Any CPU.ActiveCfg = Release|Any CPU {92A0A603-CFF6-498E-BED8-8D0D21561AC2}.Release|Any CPU.Build.0 = Release|Any CPU + {92A0A603-CFF6-498E-BED8-8D0D21561AC2}.Release|x64.ActiveCfg = Release|Any CPU + {92A0A603-CFF6-498E-BED8-8D0D21561AC2}.Release|x64.Build.0 = Release|Any CPU + {92A0A603-CFF6-498E-BED8-8D0D21561AC2}.Release|x86.ActiveCfg = Release|Any CPU + {92A0A603-CFF6-498E-BED8-8D0D21561AC2}.Release|x86.Build.0 = Release|Any CPU {BC54B3A6-736A-4459-9F33-1101013627F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {BC54B3A6-736A-4459-9F33-1101013627F6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BC54B3A6-736A-4459-9F33-1101013627F6}.Debug|x64.ActiveCfg = Debug|Any CPU + {BC54B3A6-736A-4459-9F33-1101013627F6}.Debug|x64.Build.0 = Debug|Any CPU + {BC54B3A6-736A-4459-9F33-1101013627F6}.Debug|x86.ActiveCfg = Debug|Any CPU + {BC54B3A6-736A-4459-9F33-1101013627F6}.Debug|x86.Build.0 = Debug|Any CPU {BC54B3A6-736A-4459-9F33-1101013627F6}.Release|Any CPU.ActiveCfg = Release|Any CPU {BC54B3A6-736A-4459-9F33-1101013627F6}.Release|Any CPU.Build.0 = Release|Any CPU + {BC54B3A6-736A-4459-9F33-1101013627F6}.Release|x64.ActiveCfg = Release|Any CPU + {BC54B3A6-736A-4459-9F33-1101013627F6}.Release|x64.Build.0 = Release|Any CPU + {BC54B3A6-736A-4459-9F33-1101013627F6}.Release|x86.ActiveCfg = Release|Any CPU + {BC54B3A6-736A-4459-9F33-1101013627F6}.Release|x86.Build.0 = Release|Any CPU + {97EE14CB-2173-4889-8F2E-6C73B8C9F744}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {97EE14CB-2173-4889-8F2E-6C73B8C9F744}.Debug|Any CPU.Build.0 = Debug|Any CPU + {97EE14CB-2173-4889-8F2E-6C73B8C9F744}.Debug|x64.ActiveCfg = Debug|Any CPU + {97EE14CB-2173-4889-8F2E-6C73B8C9F744}.Debug|x64.Build.0 = Debug|Any CPU + {97EE14CB-2173-4889-8F2E-6C73B8C9F744}.Debug|x86.ActiveCfg = Debug|Any CPU + {97EE14CB-2173-4889-8F2E-6C73B8C9F744}.Debug|x86.Build.0 = Debug|Any CPU + {97EE14CB-2173-4889-8F2E-6C73B8C9F744}.Release|Any CPU.ActiveCfg = Release|Any CPU + {97EE14CB-2173-4889-8F2E-6C73B8C9F744}.Release|Any CPU.Build.0 = Release|Any CPU + {97EE14CB-2173-4889-8F2E-6C73B8C9F744}.Release|x64.ActiveCfg = Release|Any CPU + {97EE14CB-2173-4889-8F2E-6C73B8C9F744}.Release|x64.Build.0 = Release|Any CPU + {97EE14CB-2173-4889-8F2E-6C73B8C9F744}.Release|x86.ActiveCfg = Release|Any CPU + {97EE14CB-2173-4889-8F2E-6C73B8C9F744}.Release|x86.Build.0 = Release|Any CPU + {71FC0F29-D903-4B5F-8918-FAB892F87147}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {71FC0F29-D903-4B5F-8918-FAB892F87147}.Debug|Any CPU.Build.0 = Debug|Any CPU + {71FC0F29-D903-4B5F-8918-FAB892F87147}.Debug|x64.ActiveCfg = Debug|Any CPU + {71FC0F29-D903-4B5F-8918-FAB892F87147}.Debug|x64.Build.0 = Debug|Any CPU + {71FC0F29-D903-4B5F-8918-FAB892F87147}.Debug|x86.ActiveCfg = Debug|Any CPU + {71FC0F29-D903-4B5F-8918-FAB892F87147}.Debug|x86.Build.0 = Debug|Any CPU + {71FC0F29-D903-4B5F-8918-FAB892F87147}.Release|Any CPU.ActiveCfg = Release|Any CPU + {71FC0F29-D903-4B5F-8918-FAB892F87147}.Release|Any CPU.Build.0 = Release|Any CPU + {71FC0F29-D903-4B5F-8918-FAB892F87147}.Release|x64.ActiveCfg = Release|Any CPU + {71FC0F29-D903-4B5F-8918-FAB892F87147}.Release|x64.Build.0 = Release|Any CPU + {71FC0F29-D903-4B5F-8918-FAB892F87147}.Release|x86.ActiveCfg = Release|Any CPU + {71FC0F29-D903-4B5F-8918-FAB892F87147}.Release|x86.Build.0 = Release|Any CPU + {BC6DECBB-ECF1-44ED-86B5-B7BE12D5998E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BC6DECBB-ECF1-44ED-86B5-B7BE12D5998E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BC6DECBB-ECF1-44ED-86B5-B7BE12D5998E}.Debug|x64.ActiveCfg = Debug|Any CPU + {BC6DECBB-ECF1-44ED-86B5-B7BE12D5998E}.Debug|x64.Build.0 = Debug|Any CPU + {BC6DECBB-ECF1-44ED-86B5-B7BE12D5998E}.Debug|x86.ActiveCfg = Debug|Any CPU + {BC6DECBB-ECF1-44ED-86B5-B7BE12D5998E}.Debug|x86.Build.0 = Debug|Any CPU + {BC6DECBB-ECF1-44ED-86B5-B7BE12D5998E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BC6DECBB-ECF1-44ED-86B5-B7BE12D5998E}.Release|Any CPU.Build.0 = Release|Any CPU + {BC6DECBB-ECF1-44ED-86B5-B7BE12D5998E}.Release|x64.ActiveCfg = Release|Any CPU + {BC6DECBB-ECF1-44ED-86B5-B7BE12D5998E}.Release|x64.Build.0 = Release|Any CPU + {BC6DECBB-ECF1-44ED-86B5-B7BE12D5998E}.Release|x86.ActiveCfg = Release|Any CPU + {BC6DECBB-ECF1-44ED-86B5-B7BE12D5998E}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -85,6 +182,9 @@ Global {BC54B3A6-736A-4459-9F33-1101013627F6} = {EB20012D-6B82-492A-A7C8-9B45D10C0364} {789D2113-F8D2-401F-87CB-4CB728AA2674} = {45312F31-98C1-4E24-9BC9-0784C6378797} {53A6F32B-CA1F-4521-BE77-3AA5031B438D} = {45312F31-98C1-4E24-9BC9-0784C6378797} + {97EE14CB-2173-4889-8F2E-6C73B8C9F744} = {EB20012D-6B82-492A-A7C8-9B45D10C0364} + {71FC0F29-D903-4B5F-8918-FAB892F87147} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} + {BC6DECBB-ECF1-44ED-86B5-B7BE12D5998E} = {932A3AFB-78D0-4A2D-BADD-54A9BC593B60} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D75591B5-4D8A-4ECD-94CE-D38BBC572272} diff --git a/src/smartsearch.md b/src/smartsearch.md deleted file mode 100644 index aab81c5..0000000 --- a/src/smartsearch.md +++ /dev/null @@ -1,609 +0,0 @@ -# SmartSearch - Documentacao Orientada a IA - -SmartSearch implementa busca declarativa para .NET usando filtros, specifiers, sorting, paginacao, selecao para DTO e execucao sobre Entity Framework Core. - -## Pacotes - -- `RoyalCode.SmartSearch.Abstractions`: contratos como `ICriteria`, `ISearch`, `ISorting`, `IResultList`. -- `RoyalCode.SmartSearch.Core`: implementacoes padrao, `Criteria`, `Search`, `CriteriaOptions`. -- `RoyalCode.SmartSearch.Linq`: geracao de expressoes, specifiers, selectors e order-by. -- `RoyalCode.SmartSearch.EntityFramework`: pipeline EF Core sobre `DbContext`. -- `RoyalCode.SmartSearch.AspNetCore`: helpers para endpoints. - -## Setup Basico com EF Core - -Registre o `DbContext` e as entidades pesquisaveis: - -```csharp -services.AddDbContext(options => options.UseSqlServer(connectionString)); - -services.AddEntityFrameworkSearches(cfg => -{ - cfg.Add(); - cfg.Add(); -}); -``` - -Tambem e possivel registrar entidades dinamicamente por `Type`: - -```csharp -services.AddEntityFrameworkSearches(cfg => -{ - foreach (var entityType in discoveredEntityTypes) - cfg.Add(entityType); -}); -``` - -Resolva `ICriteria` diretamente: - -```csharp -var criteria = scope.ServiceProvider.GetRequiredService>(); -``` - -Ou use `ISearchManager`: - -```csharp -var manager = scope.ServiceProvider.GetRequiredService>(); -var criteria = manager.Criteria(); -``` - -Tambem existe a extensao sobre `DbContext`: - -```csharp -var criteria = db.Criteria(); -``` - -## Fluxo Mental - -- Use `FilterBy(filter)` para aplicar criterios declarativos. -- Use `OrderBy(...)` para ordenar. -- Use `UsePages(...)`, `FetchPage(...)`, `Skip(...)`, `Take(...)` ou `SkipTake(...)` para limitar resultados. -- Use `Select()` quando o retorno e DTO. -- Use `UseHints(...)` quando o retorno e entidade e voce precisa carregar o grafo do agregado. -- Use `Collect()` para lista simples rastreada pelo EF. -- Use `AsSearch().ToList()` para `ResultList` com metadados de pagina. - -## Filtros - -Um filtro e uma classe com propriedades que representam criterios de busca. Valores vazios sao normalmente ignorados. - -Por convencao, toda propriedade publica do filtro vira um criterio usando o mesmo nome no modelo. Use `[Criterion]` -apenas quando precisar configurar algo: operador, caminho alvo, negacao, ignorar propriedade ou regra de valor vazio. -`[Criterion]` sem configuracao e equivalente a nao colocar atributo. - -```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 = criteria - .FilterBy(new OrderFilter { CustomerName = "Maria" }) - .Collect(); -``` - -`FilterBy` recebe um objeto filtro. Nao passe lambda/predicate para `FilterBy`. - -## Filtros Manuais por Metodo - -Quando a regra de filtro nao cabe bem em criterios por propriedade, o proprio filtro pode declarar um metodo -publico que recebe e retorna `IQueryable`. O nome recomendado e `Filter`: - -```csharp -public sealed class OrderFilter -{ - public string? Text { get; set; } - - public IQueryable Filter(IQueryable query) - { - if (!string.IsNullOrWhiteSpace(Text)) - { - query = query.Where(o => - o.Number.Contains(Text) || - o.Customer.Name.Contains(Text)); - } - - return query; - } -} -``` - -Esse metodo e descoberto automaticamente pelo SmartSearch quando nao existe specifier ja registrado ou -resolvido por DI para o par modelo/filtro. Quando ele existe, ele representa o filtro completo; as -propriedades do filtro nao sao processadas novamente pelo gerador por convencao. - -## Operadores de Filtro - -Use `CriterionAttribute` para controlar operador e caminho alvo: - -```csharp -public sealed class InvoiceFilter -{ - [Criterion("CreatedAt", CriterionOperator.GreaterThanOrEqual)] - public DateTime? CreatedAtStart { get; set; } - - [Criterion("CreatedAt", CriterionOperator.LessThanOrEqual)] - public DateTime? CreatedAtEnd { get; set; } -} -``` - -Caminhos aninhados podem ser passados pelo construtor ou por `TargetPropertyPath`: - -```csharp -[Criterion("Customer.Email")] -public string? Email { get; set; } -``` - -## OR / Disjuncao - -Use `[Disjuction("grupo")]` para combinar membros em OR: - -```csharp -public sealed class ContactFilter -{ - [Disjuction("contact")] - public string? Email { get; set; } - - [Disjuction("contact")] - public string? Phone { get; set; } -} -``` - -Tambem ha convencao por nome/caminho contendo `Or`: - -```csharp -public sealed class PersonFilter -{ - public string? FirstNameOrLastName { get; set; } -} -``` - -Tambem funciona com caminho alvo: - -```csharp -public sealed class PersonFilter -{ - [Criterion(TargetPropertyPath = "FirstNameOrLastName")] - public string? Query { get; set; } -} -``` - -Se `Or` faz parte do nome e nao deve indicar disjuncao, use `DisableOrFromName`: - -```csharp -public sealed class ProductFilter -{ - [Criterion(DisableOrFromName = true)] - public string? ColorOrSizePreference { get; set; } -} -``` - -## Filtros Complexos - -Use `[ComplexFilter]` quando uma propriedade do filtro e um objeto de valor ou subfiltro com campos -internos que devem ser aplicados contra uma propriedade complexa 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; } -} -``` - -O atributo pode ficar no tipo complexo ou diretamente na propriedade do filtro. Quando a propriedade -complexa esta nula, nenhum filtro interno e aplicado. - -Filtros complexos tambem podem combinar OR: - -```csharp -[ComplexFilter] -public struct PersonNameFilter -{ - [Criterion("FirstNameOrMiddleNameOrLastName")] - public string? Value { get; set; } -} -``` - -## Geradores Customizados de Expressao - -Use `[FilterExpressionGenerator]` quando uma propriedade de filtro precisa gerar uma expressao -LINQ propria, mas voce ainda quer manter o filtro declarativo. - -```csharp -public enum Period -{ - Today, - Last7Days, - ThisMonth -} - -public sealed class OrderFilter -{ - [Criterion("CreatedAt")] - [FilterExpressionGenerator] - public Period Period { get; set; } -} - -public sealed class PeriodExpressionGenerator : ISpecifierExpressionGenerator -{ - public static DateTime GetStart(Period period) - { - var today = DateTime.UtcNow.Date; - - return period switch - { - Period.Last7Days => today.AddDays(-7), - Period.ThisMonth => new DateTime(today.Year, today.Month, 1), - _ => today - }; - } - - public static Expression GenerateExpression(ExpressionGeneratorContext context) - { - var getStart = typeof(PeriodExpressionGenerator).GetMethod(nameof(GetStart))!; - var start = Expression.Call(getStart, context.FilterMember); - var body = Expression.GreaterThanOrEqual(context.ModelMember, start); - var lambda = Expression.Lambda(body, context.Model); - - var where = ExpressionGenerator.CreateWhereCall( - context.Model.Type, - context.Query, - lambda); - - return Expression.Assign(context.Query, where); - } -} -``` - -O generator recebe `Query`, `Filter`, `Model`, `ModelMember` e `FilterMember`. Retorne uma expressao que -atualiza a query, normalmente atribuindo um `Where(...)` de volta para `context.Query`. - -## Sorting - -Sorting dinamico usa `Sorting`: - -```csharp -criteria.OrderBy(new Sorting -{ - OrderBy = "CreatedAt", - Direction = ListSortDirection.Descending -}); -``` - -Registre order-by nomeado quando quiser mapear nomes estaveis para expressoes: - -```csharp -services.AddEntityFrameworkSearches(cfg => -{ - cfg.Add(); - cfg.AddOrderBy("CustomerName", o => o.Customer.Name); -}); -``` - -Uso: - -```csharp -criteria.OrderBy(new Sorting { OrderBy = "CustomerName" }); -``` - -## Paginacao e Limites - -`ICriteria` herda opcoes comuns: - -```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.UseLastCount(lastCount); -``` - -Para `AsSearch().ToList()`, informe pagina ou limite quando espera itens no `ResultList`: - -```csharp -var page = criteria - .UsePages(itemsPerPage: 20, pageNumber: 1) - .AsSearch() - .ToList(); -``` - -`Collect()` nao retorna metadados de pagina; ele retorna apenas os itens. - -## Terminais Comuns - -### Collect - -Materializa entidades em lista simples. No EF Core, preserva tracking. - -```csharp -IReadOnlyList orders = criteria - .FilterBy(new OrderFilter { CustomerName = "Maria" }) - .Collect(); -``` - -Async: - -```csharp -var orders = await criteria.CollectAsync(ct); -``` - -### AsSearch().ToList - -Retorna `IResultList` ou `IResultList` com metadados: - -```csharp -var result = criteria - .UsePages(20, 1) - .AsSearch() - .ToList(); - -var items = result.Items; -var total = result.Count; -``` - -Async: - -```csharp -var result = await criteria - .UsePages(20, 1) - .AsSearch() - .ToListAsync(ct); -``` - -### Exists - -Executa como existencia (`Any`). Nao aplica includes/hints. - -```csharp -var exists = criteria - .FilterBy(new OrderFilter { Id = 10 }) - .Exists(); -``` - -### FirstOrDefault - -Retorna o primeiro item ou `null`. - -```csharp -var order = criteria - .FilterBy(new OrderFilter { Number = "A-001" }) - .FirstOrDefault(); -``` - -### Single - -Retorna exatamente um item. Lanca se nao houver nenhum ou se houver mais de um. - -```csharp -var order = criteria - .FilterBy(new OrderFilter { Id = 10 }) - .Single(); -``` - -## Projecao para DTO - -Use `Select()` quando quer retorno em DTO. - -```csharp -public sealed class OrderDto -{ - public int Id { get; set; } - public string Number { get; set; } = ""; -} -``` - -Uso por selector configurado ou convencao: - -```csharp -var result = criteria - .FilterBy(new OrderFilter { CustomerName = "Maria" }) - .Select() - .UsePages(20, 1) - .AsSearch() - .ToList(); -``` - -Uso com expressao: - -```csharp -var dto = criteria - .Select(o => new OrderDto { Id = o.Id, Number = o.Number }) - .FirstOrDefault(); -``` - -Registre selector quando quiser uma expressao centralizada: - -```csharp -services.AddEntityFrameworkSearches(cfg => -{ - cfg.Add(); - cfg.AddSelector(o => new OrderDto - { - Id = o.Id, - Number = o.Number - }); -}); -``` - -## Operation Hint no SmartSearch - -SmartSearch nao expoe `Include(Expression<...>)` no contrato `ICriteria`. Para carregar navegacoes do agregado, use Operation Hint. - -Use hints quando o retorno e entidade e voce precisa carregar o grafo. Use `Select()` quando o retorno e DTO. - -### Pacotes - -No projeto EF/infra, use: - -```csharp -dotnet add package RoyalCode.OperationHint.EntityFramework -``` - -`RoyalCode.SmartSearch.EntityFramework` ja referencia `RoyalCode.OperationHint.Abstractions`. - -### Registrar Includes - -Registre o grafo uma vez por `(entidade, hint)`: - -```csharp -public enum OrderHints -{ - WithCustomer, - WithItems -} - -services.AddEntityFrameworkSearches(cfg => cfg.Add()); - -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); - })); -``` - -`AddIncludesHandler` tambem registra o handler de entidade usado por `IHintPerformer.Perform(entity, db)` no caminho pos-carga. - -### Hints por Consulta: UseHints - -`UseHints` e local da criteria e nao vaza para outras criterias no mesmo escopo. - -```csharp -var order = criteria - .FilterBy(new OrderFilter { Id = 10 }) - .UseHints(OrderHints.WithCustomer, OrderHints.WithItems) - .Single(); -``` - -Tambem funciona com `Collect`, `FirstOrDefault`, `Single`, `CollectAsync`, `FirstOrDefaultAsync`, `SingleAsync` e caminhos de entidade via `AsSearch()`: - -```csharp -var page = criteria - .UseHints(OrderHints.WithCustomer) - .UsePages(20, 1) - .AsSearch() - .ToList(); -``` - -`UseHints` exige ao menos um hint. `null` gera `ArgumentNullException`; chamada vazia gera `ArgumentException`. - -### Hints Ambiente: IHintsContainer - -Hints ambiente valem para as criterias executadas no mesmo escopo. - -```csharp -var container = scope.ServiceProvider.GetRequiredService(); -container.AddHint(OrderHints.WithCustomer); - -var orders = criteria.Collect(); -``` - -Hints ambiente e `UseHints` sao combinados. - -### Onde Hints Aplicam - -Aplicam em terminais que materializam entidade: - -- `Collect()` / `CollectAsync()` -- `FirstOrDefault()` / `FirstOrDefaultAsync()` -- `Single()` / `SingleAsync()` -- `AsSearch().ToList()` / `ToListAsync()` / `ToAsyncListAsync()` quando o resultado ainda e entidade - -Nao aplicam em: - -- `Exists()` / `ExistsAsync()` -- Depois de `Select()` -- Projecoes para DTO - -Sem OperationHint registrado, o comportamento e no-op: nenhuma navegacao e incluida. - -### Find / Pos-Carga - -SmartSearch nao fornece uma API `Find`. A paridade vem do Operation Hint: o mesmo `AddIncludesHandler` cobre query e entidade. Em um repository externo: - -```csharp -public Order? FindOrder(int id, IHintsContainer container, IHintPerformer performer, AppDbContext db) -{ - container.AddHint(OrderHints.WithItems); - - var order = db.Set().Find(id); - if (order is not null) - performer.Perform(order, db); - - return order; -} -``` - -## Exemplos Completos - -### Lista simples rastreada com filtro, sorting e hints - -```csharp -var orders = criteria - .FilterBy(new OrderFilter { CustomerName = "Maria" }) - .OrderBy(new Sorting { OrderBy = "CreatedAt", Direction = ListSortDirection.Descending }) - .UseHints(OrderHints.WithCustomer) - .Collect(); -``` - -### Pagina para UI/API - -```csharp -var page = criteria - .FilterBy(new OrderFilter { CustomerName = "Maria" }) - .UseHints(OrderHints.WithCustomer) - .UsePages(itemsPerPage: 20, pageNumber: 1) - .AsSearch() - .ToList(); -``` - -### DTO sem hints - -```csharp -var page = criteria - .FilterBy(new OrderFilter { CustomerName = "Maria" }) - .Select() - .UsePages(20, 1) - .AsSearch() - .ToList(); -``` - -## Boas Praticas - -- Modele filtros como classes pequenas e declarativas. -- Nao passe lambda para `FilterBy`; use classe filtro e atributos. -- Use `Select()` para leitura em DTO. -- Use `UseHints(...)` para carregar agregado quando o retorno e entidade. -- Para `AsSearch().ToList()`, configure pagina com `UsePages(...)` ou limite com `Take(...)`. -- Configure sortings e selectors nomeados no startup. -- Evite strings magicas de sorting espalhadas; use nomes registrados. -- Teste `Exists` e `Select` quando adicionar hints, pois eles devem permanecer sem includes. - -## Antipadroes - -- Espalhar `.Include(...)` pelos call sites em vez de registrar hints. -- Usar `UseHints` antes de `Select()` esperando carregar navegacoes. -- Usar `AsSearch().ToList()` sem pagina/limite quando espera itens. -- Criar predicates manuais quando `Criterion` cobre o caso.