AuditLog.EntityFrameworkCore.SoftDelete 0.5.0

dotnet add package AuditLog.EntityFrameworkCore.SoftDelete --version 0.5.0
                    
NuGet\Install-Package AuditLog.EntityFrameworkCore.SoftDelete -Version 0.5.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="AuditLog.EntityFrameworkCore.SoftDelete" Version="0.5.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="AuditLog.EntityFrameworkCore.SoftDelete" Version="0.5.0" />
                    
Directory.Packages.props
<PackageReference Include="AuditLog.EntityFrameworkCore.SoftDelete" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add AuditLog.EntityFrameworkCore.SoftDelete --version 0.5.0
                    
#r "nuget: AuditLog.EntityFrameworkCore.SoftDelete, 0.5.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package AuditLog.EntityFrameworkCore.SoftDelete@0.5.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=AuditLog.EntityFrameworkCore.SoftDelete&version=0.5.0
                    
Install as a Cake Addin
#tool nuget:?package=AuditLog.EntityFrameworkCore.SoftDelete&version=0.5.0
                    
Install as a Cake Tool

AuditLog

Publish to NuGet

Biblioteca de auditoria automática para EF Core com Source Generators Roslyn.

Pacotes

Pacote Descrição
AuditLog.Abstractions Contratos: AuditConfigurator<T>, IAuditDescriptor, builders
AuditLog.EntityFrameworkCore Integração EF Core: AuditSaveInterceptor, extensions
AuditLog.Generator Source generator — gera *AuditLog, maps, descriptors
AuditLog.Historico Transforma as tabelas *AuditLog em histórico legível pro usuário final
AuditLog.EntityFrameworkCore.SoftDelete Runtime: interfaces, interceptor, query filters para soft delete
AuditLog.Generator.SoftDelete Source generator — gera handlers tipados de cascade/restrict/set-null

AuditLog — Auditoria de Entidades

1. Defina um configurador

[GenerateAuditLog]
public sealed class PacienteAuditConfigurator : AuditConfigurator<Paciente>
{
    public PacienteAuditConfigurator()
    {
        For(x => x.Id).Key();
        For(x => x.Nome).HasMaxLength(200).IsRequired();
        For(x => x.Cpf).Sensitive().HasMaxLength(11);
        For(x => x.DataAtualizacao).Ignore();
    }
}

2. Adicione o interceptor no DbContext

public class AppDbContext : DbContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.AddInterceptors(new AuditSaveInterceptor());
    }
}

3. O generator produz automaticamente

  • PacienteAuditLog — tabela de auditoria com snapshot dos dados
  • PacienteAuditLogDescriptor — mapeia Paciente → PacienteAuditLog
  • PacienteAuditLogEntityMap — EF Core configuration (column types, max length)
  • ServiceCollectionExtensions.AddGeneratedAuditLogs() — DI registration

AuditLog.Historico — Histórico Legível

A tabela *AuditLog gerada por [GenerateAuditLog] guarda um snapshot textual cru de cada revisão (CamposAlteradosJson + colunas) — ótimo pra auditoria/consulta técnica, mas não pra mostrar direto ao usuário final: FK aparece como Id, enum como Enum.ToString(), data sem formatação fixa, booleano como "True"/"False". O AuditLog.Historico lê esse snapshot e resolve os campos alterados pra texto legível — o material pronto pra uma tela de "ver histórico" de um registro.

Instalação

<ItemGroup>
  <PackageReference Include="AuditLog.Historico" Version="1.0.0" />
</ItemGroup>

1. Configure os campos com AuditHistoricoConfigurator

var mapper = new AuditHistoricoMapper<ProdutoAuditLog>(cfg =>
{
    cfg.For(x => x.Nome).HasLabel("Nome");
    cfg.For(x => x.CategoriaId).HasLabel("Categoria").ResolveGuidLookup(categoriaPorId);
    cfg.For(x => x.Status).HasLabel("Status").ResolveEnum<StatusProduto>();
    cfg.For(x => x.Ativo).HasLabel("Ativo").ResolveBool();
    cfg.For(x => x.AtualizadoEm).HasLabel("Atualizado em").ResolveData();
    cfg.For(x => x.FotoId).HasLabel("Foto").IsArquivo();
});

For(x => x.Campo) é o ponto de entrada — mesma pegada do AuditConfigurator<T>.For do AuditLog.Abstractions, de propósito, pra ficar familiar pra quem já configura auditoria com esse pacote. Campo nunca configurado aqui ainda aparece no histórico, só que com o nome cru da propriedade como rótulo e sem nenhuma resolução no valor.

Métodos do AuditCampoBuilder<TLog> retornado por For:

Método Efeito
HasLabel(string label) Rótulo exibido pro campo. Sem isso, usa o nome cru da propriedade.
IsArquivo() Marca o campo como referência a um arquivo (ex: Id de upload), não texto pra exibir cru.
ResolveWith(Func<string?, string?> resolver) Resolve o valor cru com uma função arbitrária.
ResolveEnum<TEnum>() Resolve um valor gravado como Enum.ToString() (convenção do AuditLog.Generator pra campos de enum) pro [Display(Name=...)] do membro correspondente. Sem o atributo, ou se o valor não bater com nenhum membro, mantém o valor cru.
ResolveLookup(IReadOnlyDictionary<string, string> mapa) Resolve por um dicionário simples (string → label). Chave ausente mantém o valor cru.
ResolveGuidLookup(IReadOnlyDictionary<Guid, string> descricoesPorId) Resolve uma FK persistida como Guid (string) pra sua descrição, via dicionário Id → descrição (tipicamente carregado uma vez por chamada, só com os IDs vistos no lote de logs). Id sem correspondência (ex: registro referenciado foi excluído depois) mantém o valor cru.
ResolveBool(string valorVerdadeiro = "Sim", string valorFalso = "Não") Resolve um valor gravado como Convert.ToString(bool) ("True"/"False") pro rótulo desejado. Qualquer outro valor (incluindo null) passa direto.
ResolveData() Resolve data/hora gravada via Convert.ToString(...) pro formato fixo dd/MM/yyyy (ou dd/MM/yyyy HH:mm:ss quando o valor original carrega horário diferente de meia-noite). Valor que não faz parse mantém o valor cru.

2. Resolvers prontos

  • ResolveData() (usa DateResolver.ToDisplayDate internamente) — pra campos de data/hora gravados sem cultura/formato explícito pelo AuditLog.Generator. O parse roda sob a mesma CultureInfo.CurrentCulture do processo vigente na leitura; como nem a gravação (AuditSaveInterceptor) nem este resolver fixam cultura explícita, as duas rodam sob a mesma cultura do processo, o que torna o round-trip seguro independente de qual cultura seja essa.
  • ResolveEnum<TEnum>() (usa EnumDisplayResolver.ToDisplayName internamente) — resolve o nome do membro (Enum.ToString()) pro [Display(Name=...)]; sem esse atributo, tenta [Description]; sem nenhum dos dois, mantém o valor cru.

Campo sem nenhum resolver configurado aparece no histórico com o valor exatamente como foi persistido na tabela *AuditLog, sem nenhuma transformação — retornar o próprio valor de entrada é o fallback seguro nesse caso.

3. Monte o histórico com AuditHistoricoMapper<TLog>

IReadOnlyList<AuditHistoricoEntrada<ProdutoAuditLog>> entradas = mapper.Mapear(logs);

TLog precisa seguir a convenção fixa que o AuditLog.Generator sempre produz nos tipos *AuditLog: propriedades Id (long), Operacao (AuditOperation), OcorridoEm (DateTimeOffset), UsuarioId/CorrelationId/CamposAlteradosJson (string?) e AuditLogAnteriorId (long?). Se TLog não seguir essa convenção, o construtor do mapper falha cedo com uma mensagem indicando qual propriedade está faltando, em vez de deixar o mapeamento silenciosamente incompleto.

Mapear espera o lote completo de revisões relevantes: a revisão "anterior" de cada log é resolvida via AuditLogAnteriorId dentro do próprio lote passado, sem nenhuma consulta adicional. Só entradas com Operacao == AuditOperation.Modified têm CamposAlterados preenchido — Added/Deleted retornam lista vazia.

Resultado, um AuditHistoricoEntrada<TLog> por log:

Propriedade Descrição
Id Id da linha na tabela *AuditLog.
Operacao AuditOperation (Added/Modified/Deleted).
OcorridoEm DateTimeOffset do evento.
UsuarioId Autor da alteração, como persistido pelo AuditSaveInterceptor.
CorrelationId Correlation id da requisição/job que gerou o log.
CamposAlterados Lista de AuditHistoricoCampo — só preenchida quando Operacao == Modified.
Log A linha *AuditLog original — nada no formato comum cobre concerns específicos da entidade (ex: qual participante foi alterado), então o chamador recorre a este valor pra montar a descrição/agrupamento que só ele conhece.

E cada AuditHistoricoCampo:

Propriedade Descrição
Campo Rótulo configurado via HasLabel, ou o nome cru da propriedade se não configurado.
ValorAnterior Valor antes da alteração, já resolvido — null se não havia revisão anterior.
ValorNovo Valor depois da alteração, já resolvido.
EhArquivo true quando o campo foi marcado com IsArquivo() — sinaliza pro consumidor (ex: a UI) que o valor é um identificador de arquivo, não texto pra exibir cru.

Exemplo completo

// ProdutoAuditLog é gerado por [GenerateAuditLog] a partir de um AuditConfigurator<Produto>.
var logs = await db.Set<ProdutoAuditLog>()
    .AsNoTracking()
    .Where(log => log.ProdutoId == produtoId)
    .ToListAsync(ct);

// Lookup construído só com os IDs de categoria que aparecem no lote — nunca a tabela inteira.
var categoriaIds = logs
    .SelectMany(l => new[] { l.CategoriaId })
    .Where(v => Guid.TryParse(v, out _))
    .Select(v => Guid.Parse(v!))
    .Distinct()
    .ToList();

var categoriaPorId = await db.Categorias
    .AsNoTracking()
    .Where(c => categoriaIds.Contains(c.Id))
    .ToDictionaryAsync(c => c.Id, c => c.Nome, ct);

var mapper = new AuditHistoricoMapper<ProdutoAuditLog>(cfg =>
{
    cfg.For(x => x.Nome).HasLabel("Nome");
    cfg.For(x => x.CategoriaId).HasLabel("Categoria").ResolveGuidLookup(categoriaPorId);
    cfg.For(x => x.Status).HasLabel("Status").ResolveEnum<StatusProduto>();
    cfg.For(x => x.Ativo).HasLabel("Ativo").ResolveBool();
    cfg.For(x => x.AtualizadoEm).HasLabel("Atualizado em").ResolveData();
});

var entradas = mapper.Mapear(logs);

Uma entrada resultante (simplificada):

new AuditHistoricoEntrada<ProdutoAuditLog>(
    Id: 42,
    Operacao: AuditOperation.Modified,
    OcorridoEm: DateTimeOffset.Parse("2026-08-10T14:32:00-03:00"),
    UsuarioId: "3f2a1c4e-7b21-4e9a-9c31-2a8f0b1d5e6a",
    CorrelationId: "9b7e2f31-...",
    CamposAlterados:
    [
        new AuditHistoricoCampo("Categoria", ValorAnterior: "Descartáveis", ValorNovo: "Insumos", EhArquivo: false),
        new AuditHistoricoCampo("Ativo", ValorAnterior: "Sim", ValorNovo: "Não", EhArquivo: false),
        new AuditHistoricoCampo("Atualizado em", ValorAnterior: "10/08/2026 11:00:00", ValorNovo: "10/08/2026 14:32:00", EhArquivo: false)
    ],
    Log: /* a linha ProdutoAuditLog original */);

Cabe ao consumidor (controller, DTO de aplicação) projetar AuditHistoricoEntrada<TLog> pro formato exposto na API — o pacote não define esse contrato de saída de propósito, porque cada aplicação tem seus próprios campos de exibição (ex: nome do usuário resolvido a partir de UsuarioId, descrição textual da operação).

Relação com o resto da suíte

  • Depende só de AuditLog.Abstractions (usa AuditOperation) — sem dependência de EF Core nem de nenhum framework web.
  • Consome as tabelas *AuditLog produzidas por [GenerateAuditLog] (AuditLog.Generator) — não gera nem grava nada, só lê e resolve o que já foi persistido.
  • Mapear recebe qualquer IEnumerable<TLog> já carregado — a origem das linhas (EF Core, outra fonte) é responsabilidade do chamador.

SoftDelete — Exclusão Lógica

Dois pacotes complementares:

Package Função
AuditLog.EntityFrameworkCore.SoftDelete Runtime: interceptor, interfaces, query filters
AuditLog.Generator.SoftDelete Source generator (opcional): gera handlers com tipagem forte

Instalação

<ItemGroup>
  <PackageReference Include="AuditLog.EntityFrameworkCore.SoftDelete" Version="1.0.0" />
  <PackageReference Include="AuditLog.Generator.SoftDelete" Version="1.0.0" />
</ItemGroup>

1. Implemente ISoftDeleteEntity nas entidades

public class Paciente : ISoftDeleteEntity
{
    public Guid Id { get; set; }
    public string Nome { get; set; }

    // Obrigatório para soft delete
    public bool IsDeleted { get; set; }
    public DateTime? DeletedAt { get; set; }

    // Relacionamentos (Fluent API configurada no DbContext)
    public List<Notificacao> Notificacoes { get; set; } = [];
}

2. Marque o DbContext com [GenerateSoftDelete]

using AuditLog.EntityFrameworkCore.SoftDelete;

[GenerateSoftDelete]
public class AppDbContext : DbContext
{
    public DbSet<Paciente> Pacientes { get; set; }
    public DbSet<Notificacao> Notificacoes { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Paciente>(e =>
        {
            e.HasMany(x => x.Notificacoes)
                .WithOne(x => x.Paciente)
                .HasForeignKey(x => x.PacienteId)
                .OnDelete(DeleteBehavior.Cascade);
        });

        modelBuilder.ApplySoftDeleteQueryFilter();
    }
}

3. Registre na DI

// Com o source generator (handlers tipados)
var registry = new SoftDeleteHandlerRegistry();
registry.AddGeneratedSoftDeleteHandlers();
services.AddSoftDelete(registry);

// Ou sem o generator (fallback via reflection)
services.AddSoftDelete();

4. Use normalmente

db.Pacientes.Remove(paciente);
await db.SaveChangesAsync();
// → IsDeleted = true, DeletedAt = now
// → Cascade: Notificacoes também são marcadas como deletadas
// → Query filter global: db.Pacientes retorna apenas não-deletados

Comportamentos por FK

OnDelete() Efeito
Cascade Dependentes são soft-deletados recursivamente
Restrict Lança RestrictDeleteViolationException se houver dependentes
SetNull FK dos dependentes é setada como null

Convenções (quando OnDelete não é especificado)

Navigation Comportamento
Collection (List<T>) Cascade
Reference (T) Restrict
FK nullable (Guid?) SetNull

Consultas

// Query filter automático — só não-deletados
db.Pacientes.ToList();

// Incluir deletados
db.Pacientes.IgnoreQueryFilters().ToList();

Suporte a herança indireta de IEntityTypeConfiguration<T>

O gerador detecta entity maps que implementam IEntityTypeConfiguration<T> através de toda a cadeia de herança, incluindo casos como AuditEntityMap<T>IContextEntityMap<T>IEntityTypeConfiguration<T>. Isso funciona tanto com ApplyConfiguration(new ConcreteEntityMap()) quanto com ApplyConfigurationsFromAssembly().

Descoberta de entidades sem DbSet<T>

O gerador descobre entidades mesmo quando o DbContext não possui propriedades DbSet<T>, desde que as entidades sejam registradas via modelBuilder.Entity<T>() no OnModelCreating ou via ApplyConfiguration/ApplyConfigurationsFromAssembly com entity maps que implementam IEntityTypeConfiguration<T>.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.5.0 65 8/11/2026
0.4.12 78 8/10/2026
0.4.11 463 7/28/2026
0.4.10 96 7/28/2026
0.4.9 112 7/28/2026
0.4.8 93 7/28/2026
0.4.7 299 7/23/2026
0.4.6 105 7/23/2026
0.4.5 119 7/23/2026
0.4.4 112 7/22/2026
0.4.3 104 7/22/2026
0.4.2 82 7/22/2026
0.4.1 92 7/22/2026
0.4.0 102 7/22/2026
0.3.3 122 7/20/2026
0.3.1 86 7/20/2026
0.3.0 96 7/20/2026
0.2.0 121 6/30/2026