From 9a91299352b0b7b3917820bdd2a6ac69a81aaa81 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sun, 26 Jul 2026 08:06:41 -0300 Subject: [PATCH 01/20] fix: distinguish nested member paths --- docs/sdd/etapa-2/01-member-path.md | 202 ++++++++++++++++++ docs/sdd/etapa-2/README.md | 43 ++++ docs/sdd/etapa-2/decisions.md | 13 ++ docs/sdd/etapa-2/status.md | 8 + src/Dapper.FluentMap/Mapping/EntityMap.cs | 11 +- src/Dapper.FluentMap/Mapping/MemberPath.cs | 164 ++++++++++++++ src/Dapper.FluentMap/Mapping/PropertyMap.cs | 14 +- .../Mapping/PropertyMapIdentity.cs | 46 ++++ src/Dapper.FluentMap/MappingRegistry.cs | 3 +- .../Utils/ReflectionHelper.cs | 57 +++-- .../ManualMappingTests.cs | 53 +++++ .../MappingCompositionTests.cs | 37 ++++ .../Dapper.FluentMap.Tests/MemberPathTests.cs | 113 ++++++++++ 13 files changed, 746 insertions(+), 18 deletions(-) create mode 100644 docs/sdd/etapa-2/01-member-path.md create mode 100644 docs/sdd/etapa-2/README.md create mode 100644 docs/sdd/etapa-2/decisions.md create mode 100644 docs/sdd/etapa-2/status.md create mode 100644 src/Dapper.FluentMap/Mapping/MemberPath.cs create mode 100644 src/Dapper.FluentMap/Mapping/PropertyMapIdentity.cs create mode 100644 test/Dapper.FluentMap.Tests/MemberPathTests.cs diff --git a/docs/sdd/etapa-2/01-member-path.md b/docs/sdd/etapa-2/01-member-path.md new file mode 100644 index 0000000..695cc1d --- /dev/null +++ b/docs/sdd/etapa-2/01-member-path.md @@ -0,0 +1,202 @@ +# 01 - MemberPath + +## Specification + +Introduzir uma representacao interna robusta de caminho de membro para diferenciar propriedades que compartilham o mesmo nome terminal, como: + +```csharp +x => x.Rank.Level +x => x.Seniority.Level +``` + +Requisitos: + +- representar todos os membros do caminho; +- preservar ordem; +- fornecer igualdade e hashing consistentes; +- suportar caminhos simples e aninhados; +- aceitar `Convert` produzido por `Expression>`; +- preservar a API publica baseada em `PropertyInfo` terminal; +- nao implementar materializacao de objetos aninhados. + +## Discovery + +Arquivos analisados: + +- `docs/sdd/etapa-1/README.md` +- `docs/sdd/etapa-1/status.md` +- `docs/sdd/etapa-1/decisions.md` +- `docs/sdd/etapa-1/04-mapping-registry-cache.md` +- `src/Dapper.FluentMap/Mapping/EntityMap.cs` +- `src/Dapper.FluentMap/Mapping/PropertyMap.cs` +- `src/Dapper.FluentMap/Utils/ReflectionHelper.cs` +- `src/Dapper.FluentMap/MappingRegistry.cs` +- `src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs` +- `test/Dapper.FluentMap.Tests/ManualMappingTests.cs` +- `test/Dapper.FluentMap.Tests/ReflectionHelperTests.cs` +- `test/Dapper.FluentMap.Tests/MappingCompositionTests.cs` +- `test/Dapper.FluentMap.Tests/MappingRegistryTests.cs` +- `test/Dapper.FluentMap.Tests/DapperIntegrationTests.cs` + +Achados: + +- `ReflectionHelper.GetMemberInfo` ja usa o `MemberExpression.Member` real da expression tree e aceita `Convert`, conforme decisao da Etapa 1. +- `ReflectionHelper.GetMemberInfo` retorna somente o membro terminal, perdendo a cadeia de acesso. +- `EntityMapBase.ThrowIfDuplicateMapping` detecta duplicidade por `p.PropertyInfo.Name == map.PropertyInfo.Name`. +- `MappingRegistry.IsExplicitlyMapped` tambem usa somente `PropertyInfo.Name` para impedir que conventions resolvam propriedades explicitamente mapeadas. +- `PropertyMap` preserva apenas `PropertyInfo`, `ColumnName`, `CaseSensitive` e `Ignored`; nao existe identidade interna de caminho. +- O cache atual (`MappingCacheKey`) usa tipo, coluna e estrategia; ele nao colide por caminho de propriedade, mas armazena apenas o `PropertyInfo` terminal resolvido. +- Conventions escaneiam propriedades publicas de instancia do tipo raiz e produzem mapas simples. + +Comportamento atual confirmado por leitura do fluxo e por teste de regressao executado antes da implementacao: + +- `Map(x => x.Rank.Level)` cria um `PropertyMap` cujo `PropertyInfo.Name` e `Level`. +- `Map(x => x.Seniority.Level)` cria outro `PropertyMap` cujo `PropertyInfo.Name` tambem e `Level`. +- A segunda chamada falha durante configuracao em `EntityMapBase.ThrowIfDuplicateMapping`, antes de cache ou materializacao. +- Comando de confirmacao: + - `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~PropertyMapShouldDistinguishNestedPropertiesWithSameTerminalName"` + - resultado antes da correcao: falha com `Duplicate mapping detected. Property 'Level' is already mapped to column 'Level'.` + +## Decision + +Representacao escolhida: + +```text +internal sealed class MemberPath +``` + +Semantica: + +- armazena uma sequencia ordenada de `PropertyInfo`; +- o membro terminal continua disponivel como `PropertyInfo`; +- caminho simples: uma propriedade, por exemplo `Name`; +- caminho aninhado: duas ou mais propriedades, por exemplo `Address.City`; +- `ToString()` retorna a string de diagnostico formada por nomes unidos por `.`; +- igualdade compara a sequencia completa de propriedades por identidade de membro; +- hashing combina todos os membros na mesma ordem; +- `Convert` e removido durante o parsing da expression; +- expressoes que nao resolvem para cadeia de propriedades continuam falhando com `ArgumentException`; +- indexers e chamadas de metodo permanecem invalidos. + +Compatibilidade: + +- `IPropertyMap.PropertyInfo` nao sera removido nem alterado; +- `PropertyMap.PropertyInfo` continua sendo o terminal; +- `MemberPath` sera interno e associado aos mapas produzidos pelo core; +- implementacoes externas de `IPropertyMap` que nao conhecem `MemberPath` recebem fallback para caminho simples baseado em `PropertyInfo`. + +Limite arquitetural: + +- esta entrega nao cria materializador aninhado; +- retornar o `PropertyInfo` terminal para o Dapper continua sendo o limite do `ITypeMap` atual. + +## Delivery + +Arquivos alterados: + +- `src/Dapper.FluentMap/Mapping/MemberPath.cs` +- `src/Dapper.FluentMap/Mapping/PropertyMapIdentity.cs` +- `src/Dapper.FluentMap/Utils/ReflectionHelper.cs` +- `src/Dapper.FluentMap/Mapping/PropertyMap.cs` +- `src/Dapper.FluentMap/Mapping/EntityMap.cs` +- `src/Dapper.FluentMap/MappingRegistry.cs` +- `test/Dapper.FluentMap.Tests/MemberPathTests.cs` +- `test/Dapper.FluentMap.Tests/ManualMappingTests.cs` +- `test/Dapper.FluentMap.Tests/MappingCompositionTests.cs` + +Modelo anterior: + +- `ReflectionHelper` resolvia o membro correto, mas devolvia apenas o `PropertyInfo` terminal. +- `PropertyMap` armazenava apenas o terminal. +- Duplicidade e override de convention eram decididos por `PropertyInfo.Name`. + +Modelo novo: + +- `MemberPath` e uma representacao interna imutavel baseada em uma sequencia ordenada de `PropertyInfo`. +- `ReflectionHelper.GetMemberPath` percorre a cadeia da expression, remove `Convert`/`ConvertChecked` e valida que cada elo e propriedade. +- `ReflectionHelper.GetMemberInfo` continua publico e passa a devolver o terminal do `MemberPath`, preservando contrato. +- `PropertyMapBase` guarda `MemberPath` internamente, mantendo `PropertyInfo` publico como terminal. +- `PropertyMapIdentity` centraliza leitura/escrita da identidade interna e fornece fallback para caminho simples quando uma implementacao externa de `IPropertyMap` nao carrega `MemberPath`. +- `EntityMapBase.ThrowIfDuplicateMapping` compara caminhos completos. +- `MappingRegistry.IsExplicitlyMapped` compara caminhos completos para evitar que `Rank.Level` bloqueie uma convention para `Level` no tipo raiz. + +Igualdade: + +- dois `MemberPath` sao iguais quando possuem a mesma quantidade de propriedades e cada posicao representa o mesmo membro. +- a comparacao usa `Module`, `MetadataToken` e `DeclaringType` quando disponiveis, com fallback para `PropertyInfo.Equals`. +- a igualdade considera ordem, entao `Rank.Level` e diferente de `Seniority.Level`. + +Hashing: + +- o hash combina todos os membros do caminho em ordem. +- cada membro usa a mesma identidade por metadados usada na igualdade quando disponivel, com fallback para `PropertyInfo.GetHashCode`. + +Impacto nos caches: + +- `MappingCacheKey` nao mudou: continua usando tipo, nome de coluna ordinal e estrategia. +- o cache ainda retorna `PropertyInfo` terminal porque este e o contrato exigido pelo `CustomPropertyTypeMap` do Dapper. +- a correcao de identidade ocorre antes da entrada no cache, na configuracao e na composicao explicito/convention. + +Testes adicionados: + +- caminho simples (`Name`); +- caminho aninhado (`Address.City`); +- caminhos distintos com terminal igual (`Rank.Level` e `Seniority.Level`); +- igualdade/hash para o mesmo caminho; +- `Convert` em value type; +- expression invalida; +- dois nested mappings com terminal `Level` devem coexistir; +- duplicidade real do mesmo nested path deve continuar falhando; +- explicit mapping aninhado nao deve bloquear convention de propriedade raiz com mesmo terminal. + +Nao suportado nesta entrega: + +- materializacao de objetos aninhados; +- criacao automatica de objetos intermediarios; +- Value Objects ponta a ponta; +- custom materializer; +- source generator; +- query wrapper; +- naming policy baseada em caminho completo. + +## Validation + +Ambiente: + +- SDK: `10.0.302` +- test runner: VSTest com xUnit v3 +- projeto principal: `netstandard2.0` +- projetos de teste: `net10.0` +- `NUGET_PACKAGES=%TEMP%\dfm-nuget-packages-memberpath` usado para isolar o cache NuGet. + +Comandos executados: + +- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~PropertyMapShouldDistinguishNestedPropertiesWithSameTerminalName"` + - resultado antes da correcao: falhou reproduzindo a duplicidade por `Level`. +- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~MemberPathTests|FullyQualifiedName~PropertyMapShouldDistinguishNestedPropertiesWithSameTerminalName|FullyQualifiedName~DuplicateNestedPropertyPathShouldThrow|FullyQualifiedName~ExplicitNestedMappingShouldNotOverrideConvention"` + - resultado: sucesso, 9 testes aprovados. +- `dotnet restore .\Dapper.FluentMap.sln` + - resultado: sucesso. +- `dotnet build .\Dapper.FluentMap.sln --no-restore` + - resultado: sucesso, 0 warnings, 0 erros. +- `dotnet test .\Dapper.FluentMap.sln --no-build` + - resultado: sucesso, 54 testes aprovados no core e 7 testes aprovados no Dommel. +- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --no-build` + - resultado: sucesso, 54 testes aprovados. +- `dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore` + - resultado: sucesso, 0 warnings, 0 erros. +- `dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build` + - resultado: sucesso, 54 testes aprovados no core e 7 testes aprovados no Dommel. +- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --no-build` + - resultado: sucesso, 54 testes aprovados. + +Confirmacoes: + +- testes da Etapa 1 continuam passando; +- `MemberPath` diferencia `Rank.Level` de `Seniority.Level`; +- duplicidade real do mesmo caminho continua sendo detectada; +- nao houve implementacao de nested materialization; +- Dommel nao recebeu alteracao funcional; +- API publica existente foi preservada. + +Pack nao foi executado porque nao houve mudanca de empacotamento, metadados NuGet ou targets. diff --git a/docs/sdd/etapa-2/README.md b/docs/sdd/etapa-2/README.md new file mode 100644 index 0000000..137d280 --- /dev/null +++ b/docs/sdd/etapa-2/README.md @@ -0,0 +1,43 @@ +# Etapa 2 + +## Objetivo + +Fortalecer a identidade interna de membros e propriedades para preparar evolucoes seguras em validacao, heranca de mappings e naming policies. + +## Dependencia Conceitual Da Etapa 1 + +A Etapa 2 depende das decisoes da Etapa 1 sobre resolucao de expressions, composicao entre mappings explicitos e conventions, `MappingRegistry`, caches estruturados, estado global e testes de integracao. + +Antes de alterar uma decisao registrada na Etapa 1, deve existir evidencia tecnica e uma nova decisao deve ser registrada nesta pasta. + +## Escopo + +Entregas: + +1. 01 - MemberPath +2. 02 - Validacao e diagnosticos +3. 03 - Heranca de mappings +4. 04 - Naming policies + +O escopo padrao continua sendo o projeto principal `Dapper.FluentMap`. `Dapper.FluentMap.Dommel` nao deve receber alteracao funcional nesta etapa, salvo se uma mudanca comprovada no core exigir adaptacao explicita. + +## Leitura Obrigatoria + +Antes das proximas entregas, leia: + +- `docs/sdd/etapa-1/README.md` +- `docs/sdd/etapa-1/status.md` +- `docs/sdd/etapa-1/decisions.md` +- `docs/sdd/etapa-1/04-mapping-registry-cache.md` +- `docs/sdd/etapa-2/README.md` +- `docs/sdd/etapa-2/status.md` +- `docs/sdd/etapa-2/decisions.md` +- o relatorio da entrega anterior nesta pasta + +## Compatibilidade Publica + +A API publica existente deve ser preservada sempre que possivel. `PropertyInfo` exposto por `IPropertyMap.PropertyInfo` e `PropertyMap.PropertyInfo` permanece como membro terminal por compatibilidade. + +## Fora Do Escopo + +`MemberPath` representa identidade e diagnostico de caminho. Ele nao implementa materializacao de objetos aninhados, Value Objects, custom materializers, source generators, wrappers de query ou geracao de SQL. diff --git a/docs/sdd/etapa-2/decisions.md b/docs/sdd/etapa-2/decisions.md new file mode 100644 index 0000000..22b1849 --- /dev/null +++ b/docs/sdd/etapa-2/decisions.md @@ -0,0 +1,13 @@ +# Decisoes Da Etapa 2 + +Registre aqui apenas decisoes que afetem entregas posteriores. + +## MemberPath + +- A identidade interna de uma propriedade mapeada deve usar o caminho completo de propriedades, nao apenas o `PropertyInfo.Name` terminal. +- `IPropertyMap.PropertyInfo` e `PropertyMap.PropertyInfo` permanecem publicos e continuam representando o membro terminal por compatibilidade. +- `MemberPath` nao implica materializacao de objetos aninhados; futuras entregas devem tratar validacao, diagnostico e naming policies sem declarar suporte a nested materialization. +- Comparacoes entre mapping explicito e convention devem usar a identidade de caminho quando disponivel, com fallback para caminho simples baseado no `PropertyInfo` terminal para implementacoes externas de `IPropertyMap`. +- Validacoes e diagnosticos futuros devem preferir `MemberPath.ToString()` para mensagens de caminho, preservando mensagens deterministicas sem depender apenas do nome terminal. +- Heranca de mappings deve comparar membros por caminho e identidade de membro, nao por string terminal. +- Naming policies futuras podem avaliar caminho completo, mas nao devem transformar isso em suporte implicito a materializacao aninhada. diff --git a/docs/sdd/etapa-2/status.md b/docs/sdd/etapa-2/status.md new file mode 100644 index 0000000..6703aa3 --- /dev/null +++ b/docs/sdd/etapa-2/status.md @@ -0,0 +1,8 @@ +# Status Da Etapa 2 + +| Entrega | Status | Commit | +|---|---|---| +| 01 - MemberPath | Concluido | - | +| 02 - Validacao e diagnosticos | Pendente | - | +| 03 - Heranca de mappings | Pendente | - | +| 04 - Naming policies | Pendente | - | diff --git a/src/Dapper.FluentMap/Mapping/EntityMap.cs b/src/Dapper.FluentMap/Mapping/EntityMap.cs index 85f5060..f6fdf3a 100644 --- a/src/Dapper.FluentMap/Mapping/EntityMap.cs +++ b/src/Dapper.FluentMap/Mapping/EntityMap.cs @@ -57,8 +57,9 @@ protected EntityMapBase() /// when a duplicate mapping is provided. protected TPropertyMap Map(Expression> expression) { - var info = (PropertyInfo)ReflectionHelper.GetMemberInfo(expression); - var propertyMap = GetPropertyMap(info); + var memberPath = ReflectionHelper.GetMemberPath(expression); + var propertyMap = GetPropertyMap(memberPath.PropertyInfo); + PropertyMapIdentity.SetMemberPath(propertyMap, memberPath); ThrowIfDuplicateMapping(propertyMap); PropertyMaps.Add(propertyMap); return propertyMap; @@ -73,9 +74,11 @@ protected TPropertyMap Map(Expression> expression) private void ThrowIfDuplicateMapping(IPropertyMap map) { - if (PropertyMaps.Any(p => p.PropertyInfo.Name == map.PropertyInfo.Name)) + var memberPath = PropertyMapIdentity.GetMemberPath(map); + + if (PropertyMaps.Any(p => PropertyMapIdentity.GetMemberPath(p).Equals(memberPath))) { - throw new Exception($"Duplicate mapping detected. Property '{map.PropertyInfo.Name}' is already mapped to column '{map.ColumnName}'."); + throw new Exception($"Duplicate mapping detected. Property '{memberPath}' is already mapped to column '{map.ColumnName}'."); } } } diff --git a/src/Dapper.FluentMap/Mapping/MemberPath.cs b/src/Dapper.FluentMap/Mapping/MemberPath.cs new file mode 100644 index 0000000..c8e4291 --- /dev/null +++ b/src/Dapper.FluentMap/Mapping/MemberPath.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Reflection; + +namespace Dapper.FluentMap.Mapping +{ + internal sealed class MemberPath : IEquatable + { + private readonly PropertyInfo[] _properties; + private readonly ReadOnlyCollection _readOnlyProperties; + private readonly int _hashCode; + + private MemberPath(IEnumerable properties) + { + if (properties == null) + { + throw new ArgumentNullException(nameof(properties)); + } + + _properties = properties.ToArray(); + if (_properties.Length == 0) + { + throw new ArgumentException("A member path must contain at least one property.", nameof(properties)); + } + + if (_properties.Any(p => p == null)) + { + throw new ArgumentException("A member path cannot contain null properties.", nameof(properties)); + } + + _readOnlyProperties = new ReadOnlyCollection(_properties); + _hashCode = CalculateHashCode(_properties); + } + + internal IReadOnlyList Properties => _readOnlyProperties; + + internal PropertyInfo PropertyInfo => _properties[_properties.Length - 1]; + + internal bool IsNested => _properties.Length > 1; + + internal static MemberPath ForProperty(PropertyInfo property) + { + if (property == null) + { + throw new ArgumentNullException(nameof(property)); + } + + return new MemberPath(new[] { property }); + } + + internal static MemberPath FromProperties(IEnumerable properties) + { + return new MemberPath(properties); + } + + public bool Equals(MemberPath other) + { + if (ReferenceEquals(this, other)) + { + return true; + } + + if (other == null || _properties.Length != other._properties.Length) + { + return false; + } + + for (var i = 0; i < _properties.Length; i++) + { + if (!MemberEquals(_properties[i], other._properties[i])) + { + return false; + } + } + + return true; + } + + public override bool Equals(object obj) + { + return obj is MemberPath other && Equals(other); + } + + public override int GetHashCode() + { + return _hashCode; + } + + public override string ToString() + { + return string.Join(".", _properties.Select(p => p.Name)); + } + + private static bool MemberEquals(PropertyInfo left, PropertyInfo right) + { + if (ReferenceEquals(left, right)) + { + return true; + } + + if (left == null || right == null) + { + return false; + } + + if (HasSameMetadataIdentity(left, right)) + { + return true; + } + + return left.Equals(right); + } + + private static bool HasSameMetadataIdentity(PropertyInfo left, PropertyInfo right) + { + try + { + return Equals(left.Module, right.Module) && + left.MetadataToken == right.MetadataToken && + Equals(left.DeclaringType, right.DeclaringType); + } + catch (InvalidOperationException) + { + return false; + } + } + + private static int CalculateHashCode(PropertyInfo[] properties) + { + unchecked + { + var hash = 17; + + foreach (var property in properties) + { + hash = (hash * 31) + GetMemberHashCode(property); + } + + return hash; + } + } + + private static int GetMemberHashCode(PropertyInfo property) + { + try + { + unchecked + { + var hash = 17; + hash = (hash * 31) + (property.Module == null ? 0 : property.Module.GetHashCode()); + hash = (hash * 31) + property.MetadataToken; + hash = (hash * 31) + (property.DeclaringType == null ? 0 : property.DeclaringType.GetHashCode()); + return hash; + } + } + catch (InvalidOperationException) + { + return property.GetHashCode(); + } + } + } +} diff --git a/src/Dapper.FluentMap/Mapping/PropertyMap.cs b/src/Dapper.FluentMap/Mapping/PropertyMap.cs index 09bd82a..ae95780 100644 --- a/src/Dapper.FluentMap/Mapping/PropertyMap.cs +++ b/src/Dapper.FluentMap/Mapping/PropertyMap.cs @@ -34,7 +34,7 @@ public interface IPropertyMap /// Serves as the base class for all property mapping implementations. /// /// The type of the property mapping. - public abstract class PropertyMapBase + public abstract class PropertyMapBase : IPropertyMapWithMemberPath where TPropertyMap : class, IPropertyMap { /// @@ -45,6 +45,7 @@ public abstract class PropertyMapBase protected PropertyMapBase(PropertyInfo info) { PropertyInfo = info; + MemberPath = Dapper.FluentMap.Mapping.MemberPath.ForProperty(info); ColumnName = info.Name; } @@ -58,6 +59,7 @@ protected PropertyMapBase(PropertyInfo info) internal PropertyMapBase(PropertyInfo info, string columnName) { PropertyInfo = info; + MemberPath = Dapper.FluentMap.Mapping.MemberPath.ForProperty(info); ColumnName = columnName; } @@ -72,6 +74,7 @@ internal PropertyMapBase(PropertyInfo info, string columnName) internal PropertyMapBase(PropertyInfo info, string columnName, bool caseSensitive) { PropertyInfo = info; + MemberPath = Dapper.FluentMap.Mapping.MemberPath.ForProperty(info); ColumnName = columnName; CaseSensitive = caseSensitive; } @@ -96,6 +99,15 @@ internal PropertyMapBase(PropertyInfo info, string columnName, bool caseSensitiv /// public PropertyInfo PropertyInfo { get; } + internal MemberPath MemberPath { get; private set; } + + MemberPath IPropertyMapWithMemberPath.MemberPath => MemberPath; + + void IPropertyMapWithMemberPath.SetMemberPath(MemberPath memberPath) + { + MemberPath = memberPath; + } + /// /// Maps the current property to the specified column name. /// diff --git a/src/Dapper.FluentMap/Mapping/PropertyMapIdentity.cs b/src/Dapper.FluentMap/Mapping/PropertyMapIdentity.cs new file mode 100644 index 0000000..6eba4f5 --- /dev/null +++ b/src/Dapper.FluentMap/Mapping/PropertyMapIdentity.cs @@ -0,0 +1,46 @@ +using System; + +namespace Dapper.FluentMap.Mapping +{ + internal interface IPropertyMapWithMemberPath + { + MemberPath MemberPath { get; } + + void SetMemberPath(MemberPath memberPath); + } + + internal static class PropertyMapIdentity + { + internal static MemberPath GetMemberPath(IPropertyMap propertyMap) + { + if (propertyMap == null) + { + throw new ArgumentNullException(nameof(propertyMap)); + } + + var mapWithPath = propertyMap as IPropertyMapWithMemberPath; + if (mapWithPath != null && mapWithPath.MemberPath != null) + { + return mapWithPath.MemberPath; + } + + return MemberPath.ForProperty(propertyMap.PropertyInfo); + } + + internal static void SetMemberPath(IPropertyMap propertyMap, MemberPath memberPath) + { + if (propertyMap == null) + { + throw new ArgumentNullException(nameof(propertyMap)); + } + + if (memberPath == null) + { + throw new ArgumentNullException(nameof(memberPath)); + } + + var mapWithPath = propertyMap as IPropertyMapWithMemberPath; + mapWithPath?.SetMemberPath(memberPath); + } + } +} diff --git a/src/Dapper.FluentMap/MappingRegistry.cs b/src/Dapper.FluentMap/MappingRegistry.cs index da58eb6..c7997b6 100644 --- a/src/Dapper.FluentMap/MappingRegistry.cs +++ b/src/Dapper.FluentMap/MappingRegistry.cs @@ -183,7 +183,8 @@ private PropertyInfo ResolveConventionPropertyInfo(Type type, string columnName, private static bool IsExplicitlyMapped(IPropertyMap conventionMap, IList explicitPropertyMaps) { - return explicitPropertyMaps.Any(map => map.PropertyInfo.Name == conventionMap.PropertyInfo.Name); + var conventionPath = PropertyMapIdentity.GetMemberPath(conventionMap); + return explicitPropertyMaps.Any(map => PropertyMapIdentity.GetMemberPath(map).Equals(conventionPath)); } private static bool MatchColumnNames(IPropertyMap map, string columnName) diff --git a/src/Dapper.FluentMap/Utils/ReflectionHelper.cs b/src/Dapper.FluentMap/Utils/ReflectionHelper.cs index 8816f58..a5438fb 100644 --- a/src/Dapper.FluentMap/Utils/ReflectionHelper.cs +++ b/src/Dapper.FluentMap/Utils/ReflectionHelper.cs @@ -1,6 +1,8 @@ -using System; +using System; +using System.Collections.Generic; using System.Linq.Expressions; using System.Reflection; +using Dapper.FluentMap.Mapping; namespace Dapper.FluentMap.Utils { @@ -15,40 +17,71 @@ public static class ReflectionHelper /// A lamba expression containing a MemberExpression. /// A object for the member in the specified lambda expression. public static MemberInfo GetMemberInfo(LambdaExpression lambda) + { + return GetMemberPath(lambda).PropertyInfo; + } + + internal static MemberPath GetMemberPath(LambdaExpression lambda) { if (lambda == null) { throw new ArgumentNullException(nameof(lambda)); } - Expression expr = lambda; + var properties = new Stack(); + var expr = RemoveConvert(lambda.Body); + while (true) { - switch (expr.NodeType) + if (expr == null) { - case ExpressionType.Lambda: - expr = ((LambdaExpression)expr).Body; - break; - - case ExpressionType.Convert: - expr = ((UnaryExpression)expr).Operand; - break; + throw new ArgumentException($"Expression '{lambda}' must resolve to a property path.", nameof(lambda)); + } + switch (expr.NodeType) + { case ExpressionType.MemberAccess: var memberExpression = (MemberExpression)expr; var member = memberExpression.Member; if (member is PropertyInfo propertyInfo) { - return propertyInfo; + if (propertyInfo.GetIndexParameters().Length > 0) + { + throw new ArgumentException($"Expression '{lambda}' refers to indexed property '{member.Name}', which is not supported.", nameof(lambda)); + } + + properties.Push(propertyInfo); + expr = RemoveConvert(memberExpression.Expression); + break; } throw new ArgumentException($"Expression '{lambda}' refers to member '{member.Name}', which is not a property.", nameof(lambda)); + case ExpressionType.Parameter: + if (properties.Count == 0) + { + throw new ArgumentException($"Expression '{lambda}' must resolve to a property path.", nameof(lambda)); + } + + return MemberPath.FromProperties(properties); + default: - throw new ArgumentException($"Expression '{lambda}' must resolve to a property.", nameof(lambda)); + throw new ArgumentException($"Expression '{lambda}' must resolve to a property path.", nameof(lambda)); } } } + + private static Expression RemoveConvert(Expression expression) + { + while (expression != null && + (expression.NodeType == ExpressionType.Convert || + expression.NodeType == ExpressionType.ConvertChecked)) + { + expression = ((UnaryExpression)expression).Operand; + } + + return expression; + } } } diff --git a/test/Dapper.FluentMap.Tests/ManualMappingTests.cs b/test/Dapper.FluentMap.Tests/ManualMappingTests.cs index 7071fd0..f319fc5 100644 --- a/test/Dapper.FluentMap.Tests/ManualMappingTests.cs +++ b/test/Dapper.FluentMap.Tests/ManualMappingTests.cs @@ -147,6 +147,24 @@ public void PropertyMapShouldMapValueObjectProperties() Assert.Equal(typeof(EmailTestValueObject), email.PropertyInfo.DeclaringType); } + [Fact] + public void PropertyMapShouldDistinguishNestedPropertiesWithSameTerminalName() + { + PreTest(); + + var map = new NestedLevelMap(); + + Assert.Equal(2, map.PropertyMaps.Count); + } + + [Fact] + public void DuplicateNestedPropertyPathShouldThrow_Exception() + { + PreTest(); + + Assert.Throws(() => new DuplicateNestedLevelMap()); + } + private static void PreTest() { FluentMapper.Reset(typeof(TestEntity), typeof(DerivedTestEntity), typeof(ValueObjectTestEntity)); @@ -205,5 +223,40 @@ public ValueObjectMap() Map(x => x.Email.Address).ToColumn("email"); } } + + private class NestedLevelMap : EntityMap + { + public NestedLevelMap() + { + Map(x => x.Rank.Level).ToColumn("rank_level"); + Map(x => x.Seniority.Level).ToColumn("seniority_level"); + } + } + + private class DuplicateNestedLevelMap : EntityMap + { + public DuplicateNestedLevelMap() + { + Map(x => x.Rank.Level).ToColumn("rank_level"); + Map(x => x.Rank.Level).ToColumn("rank_level_again"); + } + } + + private class NestedLevelEntity + { + public RankInfo Rank { get; set; } + + public SeniorityInfo Seniority { get; set; } + } + + private class RankInfo + { + public int Level { get; set; } + } + + private class SeniorityInfo + { + public int Level { get; set; } + } } } diff --git a/test/Dapper.FluentMap.Tests/MappingCompositionTests.cs b/test/Dapper.FluentMap.Tests/MappingCompositionTests.cs index 89ba6fe..8e17055 100644 --- a/test/Dapper.FluentMap.Tests/MappingCompositionTests.cs +++ b/test/Dapper.FluentMap.Tests/MappingCompositionTests.cs @@ -85,6 +85,23 @@ public void ExplicitMappingShouldOverrideConventionForSameProperty() Assert.Equal(typeof(ExplicitOverrideEntity).GetProperty(nameof(ExplicitOverrideEntity.Id)), explicitMember.Property); } + [Fact] + public void ExplicitNestedMappingShouldNotOverrideConventionForDistinctPropertyWithSameTerminalName() + { + PreTest(typeof(NestedExplicitWithConventionEntity)); + + FluentMapper.Initialize(c => + { + c.AddMap(new NestedExplicitWithConventionMap()); + c.AddConvention().ForEntity(); + }); + + var conventionMember = SqlMapper.GetTypeMap(typeof(NestedExplicitWithConventionEntity)).GetMember("colLevel"); + + Assert.NotNull(conventionMember); + Assert.Equal(typeof(NestedExplicitWithConventionEntity).GetProperty(nameof(NestedExplicitWithConventionEntity.Level)), conventionMember.Property); + } + [Fact] public void RegistrationOrderShouldNotMatterWhenExplicitMappingIsAddedFirst() { @@ -214,6 +231,26 @@ public ExplicitOverrideMap() } } + private class NestedExplicitWithConventionEntity + { + public int Level { get; set; } + + public NestedRankInfo Rank { get; set; } + } + + private class NestedRankInfo + { + public int Level { get; set; } + } + + private class NestedExplicitWithConventionMap : EntityMap + { + public NestedExplicitWithConventionMap() + { + Map(e => e.Rank.Level).ToColumn("rank_level"); + } + } + private class MapFirstEntity { public int Id { get; set; } diff --git a/test/Dapper.FluentMap.Tests/MemberPathTests.cs b/test/Dapper.FluentMap.Tests/MemberPathTests.cs new file mode 100644 index 0000000..9a6560f --- /dev/null +++ b/test/Dapper.FluentMap.Tests/MemberPathTests.cs @@ -0,0 +1,113 @@ +using System; +using System.Linq; +using System.Linq.Expressions; +using Dapper.FluentMap.Utils; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class MemberPathTests + { + [Fact] + public void GetMemberPathShouldReturnSimplePath() + { + Expression> expression = e => e.Name; + + var memberPath = ReflectionHelper.GetMemberPath(expression); + + Assert.False(memberPath.IsNested); + Assert.Equal("Name", memberPath.ToString()); + Assert.Equal(typeof(MemberPathEntity).GetProperty(nameof(MemberPathEntity.Name)), memberPath.PropertyInfo); + Assert.Equal(new[] { "Name" }, memberPath.Properties.Select(p => p.Name)); + } + + [Fact] + public void GetMemberPathShouldReturnNestedPathInOrder() + { + Expression> expression = e => e.Address.City; + + var memberPath = ReflectionHelper.GetMemberPath(expression); + + Assert.True(memberPath.IsNested); + Assert.Equal("Address.City", memberPath.ToString()); + Assert.Equal(typeof(MemberPathEntity).GetProperty(nameof(MemberPathEntity.Address)), memberPath.Properties[0]); + Assert.Equal(typeof(AddressInfo).GetProperty(nameof(AddressInfo.City)), memberPath.Properties[1]); + Assert.Equal(typeof(AddressInfo).GetProperty(nameof(AddressInfo.City)), memberPath.PropertyInfo); + } + + [Fact] + public void MemberPathShouldDistinguishPathsWithSameTerminalPropertyName() + { + Expression> rankExpression = e => e.Rank.Level; + Expression> seniorityExpression = e => e.Seniority.Level; + + var rankPath = ReflectionHelper.GetMemberPath(rankExpression); + var seniorityPath = ReflectionHelper.GetMemberPath(seniorityExpression); + + Assert.NotEqual(rankPath, seniorityPath); + Assert.Equal("Rank.Level", rankPath.ToString()); + Assert.Equal("Seniority.Level", seniorityPath.ToString()); + Assert.Equal(rankPath.PropertyInfo.Name, seniorityPath.PropertyInfo.Name); + } + + [Fact] + public void MemberPathShouldTreatSamePathAsEqual() + { + Expression> firstExpression = e => e.Rank.Level; + Expression> secondExpression = e => e.Rank.Level; + + var firstPath = ReflectionHelper.GetMemberPath(firstExpression); + var secondPath = ReflectionHelper.GetMemberPath(secondExpression); + + Assert.Equal(firstPath, secondPath); + Assert.Equal(firstPath.GetHashCode(), secondPath.GetHashCode()); + } + + [Fact] + public void GetMemberPathShouldHandleConvertForValueTypes() + { + Expression> expression = e => e.Rank.Level; + + var memberPath = ReflectionHelper.GetMemberPath(expression); + + Assert.Equal("Rank.Level", memberPath.ToString()); + Assert.Equal(typeof(int), memberPath.PropertyInfo.PropertyType); + } + + [Fact] + public void GetMemberPathShouldThrowArgumentExceptionForInvalidExpression() + { + Expression> expression = e => e.Name.ToString(); + + var exception = Assert.Throws(() => ReflectionHelper.GetMemberPath(expression)); + + Assert.Contains("property path", exception.Message); + } + + private class MemberPathEntity + { + public string Name { get; set; } + + public AddressInfo Address { get; set; } + + public RankInfo Rank { get; set; } + + public SeniorityInfo Seniority { get; set; } + } + + private class AddressInfo + { + public string City { get; set; } + } + + private class RankInfo + { + public int Level { get; set; } + } + + private class SeniorityInfo + { + public int Level { get; set; } + } + } +} From 8611362e625bd865b4799d7729c8e4a3f88cf047 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sun, 26 Jul 2026 08:44:15 -0300 Subject: [PATCH 02/20] feat: add mapping configuration validation --- .../etapa-2/02-configuration-validation.md | 216 +++++++++++++ docs/sdd/etapa-2/decisions.md | 10 + docs/sdd/etapa-2/status.md | 2 +- .../FluentConventionConfiguration.cs | 13 +- .../Configuration/FluentMapConfiguration.cs | 5 + .../ConventionPropertyConfiguration.cs | 15 + .../PropertyConventionConfiguration.cs | 10 + .../FluentMapConfigurationException.cs | 38 +++ src/Dapper.FluentMap/Mapping/EntityMap.cs | 5 +- src/Dapper.FluentMap/Mapping/PropertyMap.cs | 20 ++ .../MappingConfigurationValidator.cs | 223 +++++++++++++ src/Dapper.FluentMap/MappingRegistry.cs | 30 +- .../ConfigurationValidationTests.cs | 295 ++++++++++++++++++ .../ManualMappingTests.cs | 6 +- 14 files changed, 879 insertions(+), 9 deletions(-) create mode 100644 docs/sdd/etapa-2/02-configuration-validation.md create mode 100644 src/Dapper.FluentMap/FluentMapConfigurationException.cs create mode 100644 src/Dapper.FluentMap/MappingConfigurationValidator.cs create mode 100644 test/Dapper.FluentMap.Tests/ConfigurationValidationTests.cs diff --git a/docs/sdd/etapa-2/02-configuration-validation.md b/docs/sdd/etapa-2/02-configuration-validation.md new file mode 100644 index 0000000..8ee023e --- /dev/null +++ b/docs/sdd/etapa-2/02-configuration-validation.md @@ -0,0 +1,216 @@ +# 02 - Validacao E Diagnosticos De Configuracao + +## Specification + +Adicionar validacoes estruturadas para configuracoes invalidas e melhorar diagnosticos de erro, preservando configuracoes validas existentes e sem ampliar o escopo funcional do core para materializacao aninhada, ORM ou query builder. + +Casos priorizados: + +- mesma propriedade ou mesmo `MemberPath` mapeado mais de uma vez; +- caminhos distintos com o mesmo nome terminal; +- coluna duplicada quando a resolucao seria ambigua; +- expression invalida; +- convention ambigua; +- incoerencia de case sensitivity; +- metadata de propriedade incompativel com a entidade; +- pontos que lancavam `Exception` generica. + +## Discovery + +Arquivos analisados: + +- `docs/sdd/etapa-1/README.md` +- `docs/sdd/etapa-1/decisions.md` +- `docs/sdd/etapa-2/README.md` +- `docs/sdd/etapa-2/status.md` +- `docs/sdd/etapa-2/decisions.md` +- `docs/sdd/etapa-2/01-member-path.md` +- `src/Dapper.FluentMap/Mapping/EntityMap.cs` +- `src/Dapper.FluentMap/Mapping/PropertyMap.cs` +- `src/Dapper.FluentMap/Mapping/MemberPath.cs` +- `src/Dapper.FluentMap/Mapping/PropertyMapIdentity.cs` +- `src/Dapper.FluentMap/MappingRegistry.cs` +- `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs` +- `src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs` +- `src/Dapper.FluentMap/Conventions/Convention.cs` +- `src/Dapper.FluentMap/Conventions/PropertyConventionConfiguration.cs` +- `src/Dapper.FluentMap/Conventions/ConventionPropertyConfiguration.cs` +- `src/Dapper.FluentMap/Utils/ReflectionHelper.cs` +- `src/Dapper.FluentMap/Utils/FluentMapConfigurationExtensions.cs` +- testes existentes do core diretamente relacionados. + +Catalogo encontrado: + +| Condicao | Excecao anterior | Momento anterior | Classificacao | Detectavel antes | +|---|---|---|---|---| +| Mesmo mapping chamado duas vezes para a mesma propriedade simples | `Exception` generica | construcao do `EntityMap` | erro de configuracao | sim | +| Mesmo `MemberPath` aninhado chamado duas vezes | `Exception` generica | construcao do `EntityMap` | erro de configuracao | sim | +| Dois paths distintos com mesmo terminal, como `Rank.Level` e `Seniority.Level` | falhava antes da Entrega 01; agora valido | configuracao | configuracao valida | sim | +| Dois maps explicitos do core para a mesma coluna | sem erro; primeiro match vencia | resolucao/materializacao | erro de configuracao | sim, no registro do map | +| Dois maps explicitos do core com colunas que colidem por case sensitivity | resultado dependia de ordem e coluna consultada | resolucao/materializacao | erro de configuracao | sim, no registro do map | +| Duas propriedades de uma convention resolvendo para a mesma coluna | `Exception` generica | `GetMember`/materializacao | erro de configuracao | sim, ao registrar a convention | +| Convention sem `Configure(...)` para regra aplicavel | `NullReferenceException` indireta | configuracao de convention | erro de configuracao | sim | +| `Map(...)` com expression que nao e caminho de propriedade | `ArgumentException` | construcao do `EntityMap` | erro imediato | sim | +| Expression nula | `ArgumentNullException` | helper de reflection | erro imediato | sim | +| Predicate/configure/transformer nulos em convention | falhas indiretas ou comportamento silencioso | configuracao | erro imediato de argumento | sim | +| `ToColumn(null)` ou `ToColumn("")` | mapeamento inutil/diagnostico tardio | configuracao | erro imediato de argumento | sim | +| `PropertyMap` sem `PropertyInfo` | `NullReferenceException` no construtor ou falha indireta | configuracao | erro imediato de argumento | sim | +| `IEntityMap` customizado com `PropertyInfo` de outro tipo | sem erro estruturado | resolucao/materializacao | erro de configuracao | sim, no registro do map | +| Registro duplicado de `EntityMap` para a mesma entidade | `InvalidOperationException` | `AddMap` | erro de configuracao | sim | +| `IgnoredPropertyInfo` com membros nao implementados | `NotImplementedException` | uso indevido do sentinel interno | erro de runtime fora do fluxo esperado | parcialmente; fora de escopo | +| Falhas de `FluentMapConfigurationExtensions` ao refletir maps de assemblies | `InvalidOperationException` | apply por assembly | diagnostico de discovery/reflection | parcialmente; fora de escopo funcional desta entrega | + +## Decision + +Nao foi adicionada API publica `Validate()`. + +Motivo: + +- os casos prioritarios encontrados sao deterministas e podem falhar cedo durante a configuracao; +- nao ha, nesta entrega, warnings agregaveis que justifiquem um contrato publico novo; +- uma API publica de diagnostico agregado exigiria definir modelo de resultado, estabilidade de mensagens, escopo de warning e interacao com estado global, o que pertence a uma evolucao posterior. + +Foi adicionada uma excecao publica: + +```text +FluentMapConfigurationException : InvalidOperationException +``` + +Motivo: + +- diferencia erros de configuracao do FluentMap de falhas arbitrarias de runtime; +- preserva compatibilidade razoavel para fluxos que ja tratavam `InvalidOperationException`; +- substitui usos de `Exception` generica em erros de configuracao controlados; +- evita hierarquia extensa. + +Classificacao das regras: + +| Regra | Decisao | +|---|---| +| `MemberPath` duplicado no mesmo `EntityMap` | erro imediato | +| registro duplicado de `EntityMap` para a mesma entidade | erro imediato | +| coluna duplicada em mappings explicitos do core da mesma entidade | erro imediato no `AddMap` | +| conflito de coluna por case sensitivity em mappings explicitos do core | erro imediato no `AddMap` | +| convention ambigua para a mesma entidade | erro imediato no registro da convention | +| expression invalida | erro imediato com `ArgumentException` | +| argumentos nulos/coluna vazia | `ArgumentNullException` ou `ArgumentException` | +| metadata de propriedade incompativel com entidade | erro imediato no `AddMap` | +| conflitos entre explicit mapping e convention para mesma coluna | fora de escopo como erro; precedencia explicita continua preservada | +| materializacao aninhada | fora de escopo | + +Formato de mensagens: + +- incluir entidade sempre que o erro for por entidade; +- incluir `MemberPath.ToString()` para caminhos; +- incluir coluna quando o conflito envolver coluna; +- incluir tipo da convention ou entity map quando a origem ajudar; +- nao depender de mensagens internas de reflection ou Dapper para explicar erros do FluentMap. + +## Delivery + +Arquivos adicionados: + +- `src/Dapper.FluentMap/FluentMapConfigurationException.cs` +- `src/Dapper.FluentMap/MappingConfigurationValidator.cs` +- `test/Dapper.FluentMap.Tests/ConfigurationValidationTests.cs` + +Arquivos alterados: + +- `src/Dapper.FluentMap/Mapping/EntityMap.cs` +- `src/Dapper.FluentMap/Mapping/PropertyMap.cs` +- `src/Dapper.FluentMap/MappingRegistry.cs` +- `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs` +- `src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs` +- `src/Dapper.FluentMap/Conventions/PropertyConventionConfiguration.cs` +- `src/Dapper.FluentMap/Conventions/ConventionPropertyConfiguration.cs` +- `test/Dapper.FluentMap.Tests/ManualMappingTests.cs` + +Implementacao: + +- `EntityMapBase.Map(...)` continua falhando cedo para `MemberPath` duplicado, agora com `FluentMapConfigurationException` e mensagem com entidade, path e colunas. +- `MappingConfigurationValidator` valida entity maps antes do registro global e conventions antes de instala-las no registry. +- conflitos de coluna sao detectados quando duas configuracoes do core podem responder pela mesma coluna, incluindo sobreposicao por case-insensitive. +- conventions continuam usando o mesmo criterio de pertencimento da resolucao existente: `ReflectedType` no target atual, com alternativa `DeclaringType` para `NETSTANDARD1_3`. +- argumentos nulos e colunas vazias em APIs fluentes agora falham com excecoes padrao de argumento. +- `ReflectionHelper` manteve `ArgumentException` para expressions invalidas; as mensagens existentes ja indicam que a expression deve resolver para um property path. + +## Compatibility + +API publica adicionada: + +- `Dapper.FluentMap.FluentMapConfigurationException`. + +API publica nao adicionada: + +- nenhum `FluentMapper.Validate()`; +- nenhum `configuration.Validate()`; +- nenhum `Explain()`. + +Comportamento preservado: + +- configuracoes validas continuam validas; +- paths distintos com mesmo nome terminal continuam coexistindo; +- extensoes de `IPropertyMap`, como Dommel, podem reutilizar coluna quando possuem semantica adicional propria; +- composicao explicit mapping -> convention -> Dapper default permanece; +- Dommel nao recebeu alteracao funcional; +- `PropertyInfo` publico segue sendo o membro terminal. + +Comportamento alterado somente para configuracoes invalidas: + +- duplicidades e ambiguidades passam a falhar cedo com diagnostico estruturado; +- alguns argumentos invalidos passam a falhar imediatamente, em vez de produzir erro indireto ou mapping inutil. + +## Tests + +Testes adicionados cobrem: + +- configuracao valida; +- `MemberPath` duplicado; +- paths distintos com mesmo nome terminal; +- registro duplicado de map; +- conflito explicito de coluna; +- conflito de coluna por case sensitivity; +- reutilizacao de coluna por `IPropertyMap` externo, preservando compatibilidade com extensoes; +- convention ambigua; +- expression invalida; +- mensagem com contexto util; +- metadata incompativel; +- convention sem `Configure(...)`. + +Como nao houve API `Validate()`, nao ha teste de validacao repetida. + +## Validation + +Comandos executados durante a entrega: + +- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~ConfigurationValidationTests"` + - resultado: sucesso, 10 testes aprovados. +- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj` + - resultado inicial: falha em `ConventionTests.ShouldMapEntitiesInAssembly` porque a validacao de convention usava compatibilidade por `DeclaringType` e classificava mapas herdados de outros tipos como duplicados. +- correcao: a validacao de convention passou a usar o mesmo filtro da resolucao (`ReflectedType == type` em `netstandard2.0`). +- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj` + - resultado: sucesso, 64 testes aprovados. +- `dotnet restore` + - resultado: sucesso. +- `dotnet build` + - resultado: sucesso, 0 warnings, 0 erros. +- `dotnet test` + - resultado inicial: falha em 3 testes do Dommel porque a regra de coluna duplicada no core tambem atingia `DommelPropertyMap`, onde a reutilizacao de coluna possui semantica adicional valida. +- correcao: a validacao de conflito de coluna foi limitada a `PropertyMap` do core e foi adicionado teste de compatibilidade para `IPropertyMap` externo. +- `dotnet test` + - resultado: sucesso, 65 testes aprovados no core e 7 testes aprovados no Dommel. +- `dotnet build --configuration Release` + - resultado: sucesso, 0 warnings, 0 erros. +- `dotnet test --configuration Release` + - resultado: sucesso, 65 testes aprovados no core e 7 testes aprovados no Dommel. + +Pack nao foi executado porque nao houve mudanca de empacotamento, metadados NuGet ou targets. + +## Limitacoes + +- Nao ha diagnostico agregado ou warnings; erros sao fail-fast. +- Conflitos de coluna em implementacoes externas de `IPropertyMap` nao sao tratados como erro pelo core, pois extensoes como Dommel podem atribuir semantica adicional a maps com a mesma coluna. +- Conflitos entre mapping explicito e convention para a mesma coluna permanecem governados pela precedencia existente e nao sao tratados como erro nesta entrega. +- O sentinel interno `IgnoredPropertyInfo` continua fora do escopo. +- `FluentMapConfigurationExtensions.ApplyMapsFromAssemblies` ainda possui diagnosticos proprios de discovery/reflection e nao foi redesenhado. +- Nao foi implementado suporte a materializacao aninhada. diff --git a/docs/sdd/etapa-2/decisions.md b/docs/sdd/etapa-2/decisions.md index 22b1849..beb9018 100644 --- a/docs/sdd/etapa-2/decisions.md +++ b/docs/sdd/etapa-2/decisions.md @@ -11,3 +11,13 @@ Registre aqui apenas decisoes que afetem entregas posteriores. - Validacoes e diagnosticos futuros devem preferir `MemberPath.ToString()` para mensagens de caminho, preservando mensagens deterministicas sem depender apenas do nome terminal. - Heranca de mappings deve comparar membros por caminho e identidade de membro, nao por string terminal. - Naming policies futuras podem avaliar caminho completo, mas nao devem transformar isso em suporte implicito a materializacao aninhada. + +## Validacao E Diagnosticos + +- Erros inequivocos de configuracao devem falhar cedo durante construcao do map ou registro em `FluentMapper.Initialize`, sem depender de query ou materializacao pelo Dapper. +- `FluentMapConfigurationException`, derivada de `InvalidOperationException`, e a excecao publica para erros de configuracao estruturados do FluentMap. +- Nao foi adicionada API publica `Validate()` nesta entrega; futuras entregas so devem cria-la se houver diagnosticos agregaveis ou warnings com contrato claro. +- Mensagens de configuracao devem incluir entidade e, quando aplicavel, `MemberPath`, coluna, tipo do map/convention e causa. +- Conflitos de coluna dentro do mesmo entity map do core ou da mesma convention sao invalidos quando mais de uma propriedade pode responder pela mesma coluna, incluindo sobreposicao por case-insensitive. +- Implementacoes externas de `IPropertyMap` nao recebem validacao global de conflito de coluna, porque integracoes como Dommel podem reutilizar colunas com semantica adicional propria. +- Conflitos entre mapping explicito e convention para a mesma coluna continuam fora do escopo de erro imediato e seguem a precedencia explicito -> convention -> Dapper default. diff --git a/docs/sdd/etapa-2/status.md b/docs/sdd/etapa-2/status.md index 6703aa3..434cad7 100644 --- a/docs/sdd/etapa-2/status.md +++ b/docs/sdd/etapa-2/status.md @@ -3,6 +3,6 @@ | Entrega | Status | Commit | |---|---|---| | 01 - MemberPath | Concluido | - | -| 02 - Validacao e diagnosticos | Pendente | - | +| 02 - Validacao e diagnosticos | Concluido | - | | 03 - Heranca de mappings | Pendente | - | | 04 - Naming policies | Pendente | - | diff --git a/src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs b/src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs index d9f0eea..27b0142 100644 --- a/src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs +++ b/src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs @@ -22,6 +22,11 @@ public class FluentConventionConfiguration /// The convention. public FluentConventionConfiguration(Convention convention) { + if (convention == null) + { + throw new ArgumentNullException(nameof(convention)); + } + _convention = convention; } @@ -107,9 +112,12 @@ private void MapProperties(Type type) .Where(c => c.PropertyPredicates.Count <= 0 || c.PropertyPredicates.All(e => e(property)))) { + MappingConfigurationValidator.ValidateConventionConfiguration(type, _convention, config); + if (!string.IsNullOrEmpty(config.PropertyConfiguration.ColumnName)) { AddConventionPropertyMap( + type, property, config.PropertyConfiguration.ColumnName, config.PropertyConfiguration.CaseSensitive); @@ -119,6 +127,7 @@ private void MapProperties(Type type) if (!string.IsNullOrEmpty(config.PropertyConfiguration.Prefix)) { AddConventionPropertyMap( + type, property, config.PropertyConfiguration.Prefix + property.Name, config.PropertyConfiguration.CaseSensitive); @@ -128,6 +137,7 @@ private void MapProperties(Type type) if (config.PropertyConfiguration.PropertyTransformer != null) { AddConventionPropertyMap( + type, property, config.PropertyConfiguration.PropertyTransformer(property.Name), config.PropertyConfiguration.CaseSensitive); @@ -136,9 +146,10 @@ private void MapProperties(Type type) } } - private void AddConventionPropertyMap(PropertyInfo property, string columnName, bool caseSensitive) + private void AddConventionPropertyMap(Type entityType, PropertyInfo property, string columnName, bool caseSensitive) { var map = new PropertyMap(property, columnName, caseSensitive); + MappingConfigurationValidator.ValidateConventionColumn(entityType, _convention, map); _convention.PropertyMaps.Add(map); } diff --git a/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs b/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs index a927eb9..df60430 100644 --- a/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs +++ b/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs @@ -21,6 +21,11 @@ public class FluentMapConfiguration /// public void AddMap(IEntityMap mapper) where TEntity : class { + if (mapper == null) + { + throw new ArgumentNullException(nameof(mapper)); + } + FluentMapper.Registry.AddEntityMap(mapper); } diff --git a/src/Dapper.FluentMap/Conventions/ConventionPropertyConfiguration.cs b/src/Dapper.FluentMap/Conventions/ConventionPropertyConfiguration.cs index a0a279c..aa5d5bb 100644 --- a/src/Dapper.FluentMap/Conventions/ConventionPropertyConfiguration.cs +++ b/src/Dapper.FluentMap/Conventions/ConventionPropertyConfiguration.cs @@ -23,6 +23,11 @@ public ConventionPropertyConfiguration() /// The same instance of . public ConventionPropertyConfiguration HasColumnName(string columnName) { + if (string.IsNullOrEmpty(columnName)) + { + throw new ArgumentException("Column name cannot be null or empty.", nameof(columnName)); + } + ColumnName = columnName; return this; } @@ -34,6 +39,11 @@ public ConventionPropertyConfiguration HasColumnName(string columnName) /// The same instance of . public ConventionPropertyConfiguration HasPrefix(string prefix) { + if (prefix == null) + { + throw new ArgumentNullException(nameof(prefix)); + } + Prefix = prefix; return this; } @@ -55,6 +65,11 @@ public ConventionPropertyConfiguration IsCaseInsensitive() /// The same instance of . public ConventionPropertyConfiguration Transform(Func transformer) { + if (transformer == null) + { + throw new ArgumentNullException(nameof(transformer)); + } + PropertyTransformer = transformer; return this; } diff --git a/src/Dapper.FluentMap/Conventions/PropertyConventionConfiguration.cs b/src/Dapper.FluentMap/Conventions/PropertyConventionConfiguration.cs index c9dbcfd..967b2c4 100644 --- a/src/Dapper.FluentMap/Conventions/PropertyConventionConfiguration.cs +++ b/src/Dapper.FluentMap/Conventions/PropertyConventionConfiguration.cs @@ -30,6 +30,11 @@ public PropertyConventionConfiguration() /// The same instance of . public PropertyConventionConfiguration Where(Func predicate) { + if (predicate == null) + { + throw new ArgumentNullException(nameof(predicate)); + } + PropertyPredicates.Add(predicate); return this; } @@ -43,6 +48,11 @@ public PropertyConventionConfiguration Where(Func predicate) /// public void Configure(Action configure) { + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + var config = new ConventionPropertyConfiguration(); PropertyConfiguration = config; configure(config); diff --git a/src/Dapper.FluentMap/FluentMapConfigurationException.cs b/src/Dapper.FluentMap/FluentMapConfigurationException.cs new file mode 100644 index 0000000..626d8c4 --- /dev/null +++ b/src/Dapper.FluentMap/FluentMapConfigurationException.cs @@ -0,0 +1,38 @@ +using System; + +namespace Dapper.FluentMap +{ + /// + /// Represents an invalid Dapper.FluentMap configuration. + /// + public class FluentMapConfigurationException : InvalidOperationException + { + /// + /// Initializes a new instance of the class. + /// + public FluentMapConfigurationException() + { + } + + /// + /// Initializes a new instance of the class + /// with the specified error message. + /// + /// The message that describes the error. + public FluentMapConfigurationException(string message) + : base(message) + { + } + + /// + /// Initializes a new instance of the class + /// with the specified error message and a reference to the inner exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception. + public FluentMapConfigurationException(string message, Exception innerException) + : base(message, innerException) + { + } + } +} diff --git a/src/Dapper.FluentMap/Mapping/EntityMap.cs b/src/Dapper.FluentMap/Mapping/EntityMap.cs index f6fdf3a..6fc1ce7 100644 --- a/src/Dapper.FluentMap/Mapping/EntityMap.cs +++ b/src/Dapper.FluentMap/Mapping/EntityMap.cs @@ -54,7 +54,7 @@ protected EntityMapBase() /// /// Expression to the property on . /// The created instance. This enables a fluent API. - /// when a duplicate mapping is provided. + /// when a duplicate mapping is provided. protected TPropertyMap Map(Expression> expression) { var memberPath = ReflectionHelper.GetMemberPath(expression); @@ -78,7 +78,8 @@ private void ThrowIfDuplicateMapping(IPropertyMap map) if (PropertyMaps.Any(p => PropertyMapIdentity.GetMemberPath(p).Equals(memberPath))) { - throw new Exception($"Duplicate mapping detected. Property '{memberPath}' is already mapped to column '{map.ColumnName}'."); + var existingMap = PropertyMaps.First(p => PropertyMapIdentity.GetMemberPath(p).Equals(memberPath)); + throw new FluentMapConfigurationException($"Property path '{memberPath}' is already mapped for entity '{typeof(TEntity).FullName}'. Existing column: '{existingMap.ColumnName}'; duplicate column: '{map.ColumnName}'."); } } } diff --git a/src/Dapper.FluentMap/Mapping/PropertyMap.cs b/src/Dapper.FluentMap/Mapping/PropertyMap.cs index ae95780..522ef1d 100644 --- a/src/Dapper.FluentMap/Mapping/PropertyMap.cs +++ b/src/Dapper.FluentMap/Mapping/PropertyMap.cs @@ -44,6 +44,11 @@ public abstract class PropertyMapBase : IPropertyMapWithMemberPath /// The object representing to the property to map. protected PropertyMapBase(PropertyInfo info) { + if (info == null) + { + throw new ArgumentNullException(nameof(info)); + } + PropertyInfo = info; MemberPath = Dapper.FluentMap.Mapping.MemberPath.ForProperty(info); ColumnName = info.Name; @@ -58,6 +63,11 @@ protected PropertyMapBase(PropertyInfo info) /// The column name in the database to map the property to. internal PropertyMapBase(PropertyInfo info, string columnName) { + if (info == null) + { + throw new ArgumentNullException(nameof(info)); + } + PropertyInfo = info; MemberPath = Dapper.FluentMap.Mapping.MemberPath.ForProperty(info); ColumnName = columnName; @@ -73,6 +83,11 @@ internal PropertyMapBase(PropertyInfo info, string columnName) /// A value indicating whether the mappig should be case sensitive. internal PropertyMapBase(PropertyInfo info, string columnName, bool caseSensitive) { + if (info == null) + { + throw new ArgumentNullException(nameof(info)); + } + PropertyInfo = info; MemberPath = Dapper.FluentMap.Mapping.MemberPath.ForProperty(info); ColumnName = columnName; @@ -116,6 +131,11 @@ void IPropertyMapWithMemberPath.SetMemberPath(MemberPath memberPath) /// The current instance of . public TPropertyMap ToColumn(string columnName, bool caseSensitive = true) { + if (string.IsNullOrEmpty(columnName)) + { + throw new ArgumentException("Column name cannot be null or empty.", nameof(columnName)); + } + ColumnName = columnName; CaseSensitive = caseSensitive; return this as TPropertyMap; diff --git a/src/Dapper.FluentMap/MappingConfigurationValidator.cs b/src/Dapper.FluentMap/MappingConfigurationValidator.cs new file mode 100644 index 0000000..723f94a --- /dev/null +++ b/src/Dapper.FluentMap/MappingConfigurationValidator.cs @@ -0,0 +1,223 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Dapper.FluentMap.Conventions; +using Dapper.FluentMap.Mapping; + +namespace Dapper.FluentMap +{ + internal static class MappingConfigurationValidator + { + internal static void ValidateEntityMap(Type entityType, IEntityMap entityMap) + { + if (entityType == null) + { + throw new ArgumentNullException(nameof(entityType)); + } + + if (entityMap == null) + { + throw new ArgumentNullException(nameof(entityMap)); + } + + var maps = GetEntityMapDescriptors(entityType, entityMap).ToList(); + ValidateDuplicateMemberPaths(entityType, maps, "entity map", entityMap.GetType()); + ValidateColumnConflicts(entityType, maps, "entity map", entityMap.GetType()); + } + + internal static void ValidateConvention(Type entityType, Convention convention) + { + if (entityType == null) + { + throw new ArgumentNullException(nameof(entityType)); + } + + if (convention == null) + { + throw new ArgumentNullException(nameof(convention)); + } + + var maps = GetConventionMapDescriptors(entityType, convention).ToList(); + ValidateDuplicateMemberPaths(entityType, maps, "convention", convention.GetType()); + ValidateColumnConflicts(entityType, maps, "convention", convention.GetType()); + } + + internal static void ValidateConventionConfiguration(Type entityType, Convention convention, PropertyConventionConfiguration configuration) + { + if (configuration.PropertyConfiguration == null) + { + throw new FluentMapConfigurationException( + $"Convention '{FormatType(convention.GetType())}' has a matching property rule without configuration for entity '{FormatType(entityType)}'. Call Configure(...) and choose a column name, prefix or transformer."); + } + } + + internal static void ValidateConventionColumn(Type entityType, Convention convention, PropertyMap propertyMap) + { + if (string.IsNullOrEmpty(propertyMap.ColumnName)) + { + throw new FluentMapConfigurationException( + $"Convention '{FormatType(convention.GetType())}' produced an empty column name for property path '{PropertyMapIdentity.GetMemberPath(propertyMap)}' on entity '{FormatType(entityType)}'."); + } + } + + private static IEnumerable GetEntityMapDescriptors(Type entityType, IEntityMap entityMap) + { + if (entityMap.PropertyMaps == null) + { + throw new FluentMapConfigurationException( + $"Entity map '{FormatType(entityMap.GetType())}' for entity '{FormatType(entityType)}' returned a null property map collection."); + } + + foreach (var map in entityMap.PropertyMaps) + { + yield return CreateDescriptor(entityType, map, entityMap.GetType(), "entity map", requireEntityCompatibility: true); + } + } + + private static IEnumerable GetConventionMapDescriptors(Type entityType, Convention convention) + { + foreach (var map in convention.PropertyMaps) + { + if (map == null) + { + throw new FluentMapConfigurationException( + $"Convention '{FormatType(convention.GetType())}' for entity '{FormatType(entityType)}' contains a null property map."); + } + + if (!IsMapForEntity(entityType, map)) + { + continue; + } + + yield return CreateDescriptor(entityType, map, convention.GetType(), "convention", requireEntityCompatibility: false); + } + } + + private static MapDescriptor CreateDescriptor(Type entityType, IPropertyMap map, Type sourceType, string sourceKind, bool requireEntityCompatibility) + { + if (map == null) + { + throw new FluentMapConfigurationException( + $"The {sourceKind} '{FormatType(sourceType)}' for entity '{FormatType(entityType)}' contains a null property map."); + } + + if (map.PropertyInfo == null) + { + throw new FluentMapConfigurationException( + $"The {sourceKind} '{FormatType(sourceType)}' for entity '{FormatType(entityType)}' contains a property map without metadata."); + } + + var memberPath = PropertyMapIdentity.GetMemberPath(map); + if (requireEntityCompatibility && !IsMemberPathCompatible(entityType, memberPath)) + { + throw new FluentMapConfigurationException( + $"Property path '{memberPath}' is not compatible with entity '{FormatType(entityType)}'. The first property is declared by '{FormatType(memberPath.Properties[0].DeclaringType)}'."); + } + + if (string.IsNullOrEmpty(map.ColumnName)) + { + throw new FluentMapConfigurationException( + $"Property path '{memberPath}' on entity '{FormatType(entityType)}' has an empty column name."); + } + + return new MapDescriptor(map, memberPath); + } + + private static void ValidateDuplicateMemberPaths(Type entityType, IList maps, string sourceKind, Type sourceType) + { + for (var i = 0; i < maps.Count; i++) + { + for (var j = i + 1; j < maps.Count; j++) + { + if (!maps[i].MemberPath.Equals(maps[j].MemberPath)) + { + continue; + } + + throw new FluentMapConfigurationException( + $"Property path '{maps[i].MemberPath}' is already mapped for entity '{FormatType(entityType)}' in {sourceKind} '{FormatType(sourceType)}'. Existing column: '{maps[i].Map.ColumnName}'; duplicate column: '{maps[j].Map.ColumnName}'."); + } + } + } + + private static void ValidateColumnConflicts(Type entityType, IList maps, string sourceKind, Type sourceType) + { + for (var i = 0; i < maps.Count; i++) + { + for (var j = i + 1; j < maps.Count; j++) + { + if (!ShouldValidateColumnConflict(maps[i].Map, maps[j].Map)) + { + continue; + } + + if (!ColumnNamesOverlap(maps[i].Map, maps[j].Map)) + { + continue; + } + + var caseSensitivity = maps[i].Map.CaseSensitive == maps[j].Map.CaseSensitive + ? string.Empty + : " The mappings use different case sensitivity settings."; + + throw new FluentMapConfigurationException( + $"Column '{maps[i].Map.ColumnName}' is configured for more than one property path on entity '{FormatType(entityType)}' in {sourceKind} '{FormatType(sourceType)}': '{maps[i].MemberPath}' and '{maps[j].MemberPath}'.{caseSensitivity}"); + } + } + } + + private static bool ColumnNamesOverlap(IPropertyMap left, IPropertyMap right) + { + if (string.Equals(left.ColumnName, right.ColumnName, StringComparison.Ordinal)) + { + return true; + } + + if (!left.CaseSensitive || !right.CaseSensitive) + { + return string.Equals(left.ColumnName, right.ColumnName, StringComparison.OrdinalIgnoreCase); + } + + return false; + } + + private static bool ShouldValidateColumnConflict(IPropertyMap left, IPropertyMap right) + { + return left.GetType() == typeof(PropertyMap) && + right.GetType() == typeof(PropertyMap); + } + + private static bool IsMapForEntity(Type entityType, IPropertyMap map) + { +#if NETSTANDARD1_3 + return map.PropertyInfo.DeclaringType == entityType; +#else + return map.PropertyInfo.ReflectedType == entityType; +#endif + } + + private static bool IsMemberPathCompatible(Type entityType, MemberPath memberPath) + { + var declaringType = memberPath.Properties[0].DeclaringType; + return declaringType != null && declaringType.IsAssignableFrom(entityType); + } + + private static string FormatType(Type type) + { + return type == null ? "" : type.FullName; + } + + private sealed class MapDescriptor + { + internal MapDescriptor(IPropertyMap map, MemberPath memberPath) + { + Map = map; + MemberPath = memberPath; + } + + internal IPropertyMap Map { get; } + + internal MemberPath MemberPath { get; } + } + } +} diff --git a/src/Dapper.FluentMap/MappingRegistry.cs b/src/Dapper.FluentMap/MappingRegistry.cs index c7997b6..71d593b 100644 --- a/src/Dapper.FluentMap/MappingRegistry.cs +++ b/src/Dapper.FluentMap/MappingRegistry.cs @@ -26,9 +26,21 @@ internal void AddEntityMap(IEntityMap mapper) where TEntity : class { var type = typeof(TEntity); + if (mapper == null) + { + throw new ArgumentNullException(nameof(mapper)); + } + + if (EntityMaps.ContainsKey(type)) + { + throw new FluentMapConfigurationException($"Entity '{type}' already has a configured entity map. Current entity maps: " + string.Join(", ", EntityMaps.Select(e => e.Key.ToString()))); + } + + MappingConfigurationValidator.ValidateEntityMap(type, mapper); + if (!EntityMaps.TryAdd(type, mapper)) { - throw new InvalidOperationException($"Adding entity map for type '{type}' failed. The type already exists. Current entity maps: " + string.Join(", ", EntityMaps.Select(e => e.Key.ToString()))); + throw new FluentMapConfigurationException($"Entity '{type}' already has a configured entity map. Current entity maps: " + string.Join(", ", EntityMaps.Select(e => e.Key.ToString()))); } InvalidateType(type); @@ -37,6 +49,18 @@ internal void AddEntityMap(IEntityMap mapper) internal void AddConvention(Type type, Convention convention) { + if (type == null) + { + throw new ArgumentNullException(nameof(type)); + } + + if (convention == null) + { + throw new ArgumentNullException(nameof(convention)); + } + + MappingConfigurationValidator.ValidateConvention(type, convention); + TypeConventions.AddOrUpdate( type, _ => new List { convention }, @@ -166,8 +190,8 @@ private PropertyInfo ResolveConventionPropertyInfo(Type type, string columnName, if (maps.Count > 1) { - const string msg = "Finding mappings for column '{0}' yielded more than 1 PropertyMap. The conventions should be more specific. Type: '{1}'. Convention: '{2}'."; - throw new Exception(string.Format(msg, columnName, type, convention)); + const string msg = "Column '{0}' matched more than one convention property map for entity '{1}' in convention '{2}'. The convention should be more specific."; + throw new FluentMapConfigurationException(string.Format(msg, columnName, type, convention.GetType())); } if (maps.Count == 0) diff --git a/test/Dapper.FluentMap.Tests/ConfigurationValidationTests.cs b/test/Dapper.FluentMap.Tests/ConfigurationValidationTests.cs new file mode 100644 index 0000000..0c90802 --- /dev/null +++ b/test/Dapper.FluentMap.Tests/ConfigurationValidationTests.cs @@ -0,0 +1,295 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Dapper.FluentMap.Conventions; +using Dapper.FluentMap.Mapping; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class ConfigurationValidationTests + { + [Fact] + public void ValidConfigurationShouldRegisterEntityMap() + { + PreTest(typeof(ValidEntity)); + + FluentMapper.Initialize(c => c.AddMap(new ValidMap())); + + Assert.True(FluentMapper.EntityMaps.ContainsKey(typeof(ValidEntity))); + } + + [Fact] + public void DuplicateMemberPathShouldThrowConfigurationException() + { + PreTest(typeof(NestedLevelEntity)); + + var exception = Assert.Throws(() => new DuplicateNestedLevelMap()); + + Assert.Contains("Rank.Level", exception.Message); + Assert.Contains(typeof(NestedLevelEntity).FullName, exception.Message); + } + + [Fact] + public void DistinctPathsWithSameTerminalNameShouldRemainValid() + { + PreTest(typeof(NestedLevelEntity)); + + var map = new DistinctNestedLevelMap(); + + Assert.Equal(2, map.PropertyMaps.Count); + } + + [Fact] + public void DuplicateEntityMapRegistrationShouldThrowConfigurationException() + { + PreTest(typeof(ValidEntity)); + + FluentMapper.Initialize(c => c.AddMap(new ValidMap())); + var exception = Assert.Throws(() => + FluentMapper.Initialize(c => c.AddMap(new AlternateValidMap()))); + + Assert.Contains(typeof(ValidEntity).FullName, exception.Message); + Assert.Contains("already has a configured entity map", exception.Message); + } + + [Fact] + public void ExplicitColumnConflictShouldThrowConfigurationException() + { + PreTest(typeof(ColumnConflictEntity)); + + var exception = Assert.Throws(() => + FluentMapper.Initialize(c => c.AddMap(new ColumnConflictMap()))); + + Assert.Contains("shared_column", exception.Message); + Assert.Contains(nameof(ColumnConflictEntity.Id), exception.Message); + Assert.Contains(nameof(ColumnConflictEntity.Name), exception.Message); + Assert.Contains(typeof(ColumnConflictEntity).FullName, exception.Message); + } + + [Fact] + public void CaseSensitivityColumnConflictShouldThrowConfigurationException() + { + PreTest(typeof(ColumnConflictEntity)); + + var exception = Assert.Throws(() => + FluentMapper.Initialize(c => c.AddMap(new CaseSensitivityConflictMap()))); + + Assert.Contains("case sensitivity", exception.Message); + Assert.Contains("shared_column", exception.Message); + Assert.Contains(typeof(ColumnConflictEntity).FullName, exception.Message); + } + + [Fact] + public void AmbiguousConventionShouldThrowConfigurationExceptionDuringConfiguration() + { + PreTest(typeof(ColumnConflictEntity)); + + var exception = Assert.Throws(() => + FluentMapper.Initialize(c => c.AddConvention().ForEntity())); + + Assert.Contains("shared_column", exception.Message); + Assert.Contains(nameof(ColumnConflictEntity.Id), exception.Message); + Assert.Contains(nameof(ColumnConflictEntity.Name), exception.Message); + Assert.Contains(typeof(AmbiguousConvention).FullName, exception.Message); + } + + [Fact] + public void InvalidExpressionShouldThrowArgumentExceptionWithUsefulMessage() + { + PreTest(typeof(ValidEntity)); + + var exception = Assert.Throws(() => new InvalidExpressionMap()); + + Assert.Contains("property path", exception.Message); + Assert.Contains("ToString", exception.Message); + } + + [Fact] + public void IncompatiblePropertyMetadataShouldThrowConfigurationException() + { + PreTest(typeof(ValidEntity)); + + var exception = Assert.Throws(() => + FluentMapper.Initialize(c => c.AddMap(new IncompatibleMetadataMap()))); + + Assert.Contains(typeof(ValidEntity).FullName, exception.Message); + Assert.Contains(typeof(ForeignMetadataEntity).FullName, exception.Message); + Assert.Contains("not compatible", exception.Message); + } + + [Fact] + public void ExternalPropertyMapsWithSameColumnShouldRemainValid() + { + PreTest(typeof(ColumnConflictEntity)); + + FluentMapper.Initialize(c => c.AddMap(new ExternalColumnReuseMap())); + + Assert.True(FluentMapper.EntityMaps.ContainsKey(typeof(ColumnConflictEntity))); + } + + [Fact] + public void ConventionWithoutConfigureShouldThrowConfigurationException() + { + PreTest(typeof(ValidEntity)); + + var exception = Assert.Throws(() => + FluentMapper.Initialize(c => c.AddConvention().ForEntity())); + + Assert.Contains(typeof(MissingConfigureConvention).FullName, exception.Message); + Assert.Contains(typeof(ValidEntity).FullName, exception.Message); + Assert.Contains("without configuration", exception.Message); + } + + private static void PreTest(params Type[] types) + { + FluentMapper.Reset(types); + } + + private class ValidEntity + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private class ValidMap : EntityMap + { + public ValidMap() + { + Map(e => e.Id).ToColumn("valid_id"); + } + } + + private class AlternateValidMap : EntityMap + { + public AlternateValidMap() + { + Map(e => e.Name).ToColumn("valid_name"); + } + } + + private class InvalidExpressionMap : EntityMap + { + public InvalidExpressionMap() + { + Map(e => e.Id.ToString()).ToColumn("id_text"); + } + } + + private class ColumnConflictEntity + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private class ColumnConflictMap : EntityMap + { + public ColumnConflictMap() + { + Map(e => e.Id).ToColumn("shared_column"); + Map(e => e.Name).ToColumn("shared_column"); + } + } + + private class CaseSensitivityConflictMap : EntityMap + { + public CaseSensitivityConflictMap() + { + Map(e => e.Id).ToColumn("shared_column", caseSensitive: false); + Map(e => e.Name).ToColumn("SHARED_COLUMN"); + } + } + + private class ExternalColumnReuseMap : IEntityMap + { + public ExternalColumnReuseMap() + { + var idMap = new ExternalPropertyMap(typeof(ColumnConflictEntity).GetProperty(nameof(ColumnConflictEntity.Id))) + .ToColumn("shared_column"); + var nameMap = new ExternalPropertyMap(typeof(ColumnConflictEntity).GetProperty(nameof(ColumnConflictEntity.Name))) + .ToColumn("shared_column"); + + PropertyMaps = new List { idMap, nameMap }; + } + + public IList PropertyMaps { get; } + } + + private class ExternalPropertyMap : PropertyMapBase, IPropertyMap + { + public ExternalPropertyMap(PropertyInfo info) + : base(info) + { + } + } + + private class AmbiguousConvention : Convention + { + public AmbiguousConvention() + { + Properties().Configure(c => c.HasColumnName("shared_column")); + } + } + + private class MissingConfigureConvention : Convention + { + public MissingConfigureConvention() + { + Properties(); + } + } + + private class NestedLevelEntity + { + public RankInfo Rank { get; set; } + + public SeniorityInfo Seniority { get; set; } + } + + private class RankInfo + { + public int Level { get; set; } + } + + private class SeniorityInfo + { + public int Level { get; set; } + } + + private class DistinctNestedLevelMap : EntityMap + { + public DistinctNestedLevelMap() + { + Map(e => e.Rank.Level).ToColumn("rank_level"); + Map(e => e.Seniority.Level).ToColumn("seniority_level"); + } + } + + private class DuplicateNestedLevelMap : EntityMap + { + public DuplicateNestedLevelMap() + { + Map(e => e.Rank.Level).ToColumn("rank_level"); + Map(e => e.Rank.Level).ToColumn("rank_level_again"); + } + } + + private class ForeignMetadataEntity + { + public int Id { get; set; } + } + + private class IncompatibleMetadataMap : IEntityMap + { + public IncompatibleMetadataMap() + { + var foreignProperty = typeof(ForeignMetadataEntity).GetProperty(nameof(ForeignMetadataEntity.Id)); + PropertyMaps = new List { new PropertyMap(foreignProperty, "foreign_id") }; + } + + public IList PropertyMaps { get; } + } + } +} diff --git a/test/Dapper.FluentMap.Tests/ManualMappingTests.cs b/test/Dapper.FluentMap.Tests/ManualMappingTests.cs index f319fc5..3869027 100644 --- a/test/Dapper.FluentMap.Tests/ManualMappingTests.cs +++ b/test/Dapper.FluentMap.Tests/ManualMappingTests.cs @@ -16,7 +16,8 @@ public void DuplicateMappingShouldThrow_Exception() PreTest(); // Act & Assert - Assert.Throws(() => new MapWithDuplicateMapping()); + var exception = Assert.Throws(() => new MapWithDuplicateMapping()); + Assert.Contains(nameof(TestEntity.Id), exception.Message); } [Fact] @@ -162,7 +163,8 @@ public void DuplicateNestedPropertyPathShouldThrow_Exception() { PreTest(); - Assert.Throws(() => new DuplicateNestedLevelMap()); + var exception = Assert.Throws(() => new DuplicateNestedLevelMap()); + Assert.Contains("Rank.Level", exception.Message); } private static void PreTest() From 5735b694350def8deec40ba9e08eae76cb2f8a4d Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sun, 26 Jul 2026 09:00:42 -0300 Subject: [PATCH 03/20] feat: support inherited entity mappings --- docs/sdd/etapa-2/03-inherited-mappings.md | 255 +++++++++ docs/sdd/etapa-2/decisions.md | 11 + docs/sdd/etapa-2/status.md | 2 +- src/Dapper.FluentMap/Mapping/EntityMap.cs | 41 +- .../MappingConfigurationValidator.cs | 30 +- src/Dapper.FluentMap/MappingRegistry.cs | 66 ++- .../InheritedMappingTests.cs | 530 ++++++++++++++++++ .../MappingCompositionTests.cs | 45 ++ 8 files changed, 975 insertions(+), 5 deletions(-) create mode 100644 docs/sdd/etapa-2/03-inherited-mappings.md create mode 100644 test/Dapper.FluentMap.Tests/InheritedMappingTests.cs diff --git a/docs/sdd/etapa-2/03-inherited-mappings.md b/docs/sdd/etapa-2/03-inherited-mappings.md new file mode 100644 index 0000000..fd1209f --- /dev/null +++ b/docs/sdd/etapa-2/03-inherited-mappings.md @@ -0,0 +1,255 @@ +# 03 - Heranca De Mappings + +## Specification + +Adicionar suporte explicito para reutilizar mappings configurados em uma classe base quando um mapping de tipo derivado optar por essa composicao. + +Problema historico: + +- um `EntityMap` configurado para `User.Id -> user_id` nao era aplicado a `AdminUser : User`; +- consumidores precisavam copiar mappings herdados para cada tipo derivado; +- inferir heranca automaticamente poderia alterar comportamento existente de forma silenciosa. + +Requisitos tratados: + +- inclusao deliberada de base mapping; +- ordem de composicao; +- precedencia entre derivado, base, convention e Dapper default; +- override de membro herdado; +- conflito de coluna entre base e derivado; +- interacao com conventions; +- preservacao de `MemberPath` herdado; +- hierarquia invalida; +- multiplos niveis de heranca; +- ordem de registro diagnostica. + +## Discovery + +Arquivos analisados: + +- `AGENTS.md` +- `docs/sdd/etapa-1/README.md` +- `docs/sdd/etapa-1/decisions.md` +- `docs/sdd/etapa-1/04-mapping-registry-cache.md` +- `docs/sdd/etapa-2/README.md` +- `docs/sdd/etapa-2/status.md` +- `docs/sdd/etapa-2/decisions.md` +- `docs/sdd/etapa-2/01-member-path.md` +- `docs/sdd/etapa-2/02-configuration-validation.md` +- `src/Dapper.FluentMap/Mapping/EntityMap.cs` +- `src/Dapper.FluentMap/Mapping/PropertyMap.cs` +- `src/Dapper.FluentMap/Mapping/MemberPath.cs` +- `src/Dapper.FluentMap/Mapping/PropertyMapIdentity.cs` +- `src/Dapper.FluentMap/MappingRegistry.cs` +- `src/Dapper.FluentMap/MappingConfigurationValidator.cs` +- `src/Dapper.FluentMap/TypeMaps/FluentTypeMap.cs` +- `src/Dapper.FluentMap/TypeMaps/FluentConventionTypeMap.cs` +- `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs` +- `src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs` +- testes do core relacionados a composicao, validacao, registry e Dapper. + +Achados: + +- `MappingRegistry.ResolveFluentPropertyInfo` ja centralizava a precedencia explicito -> convention -> Dapper default. +- `GetExplicitPropertyMaps(type)` retornava apenas o `EntityMap` registrado para o tipo exato. +- `MemberPath` ja permitia comparar propriedades herdadas por identidade de membro, nao apenas nome terminal. +- `MappingConfigurationValidator` ja validava compatibilidade de paths cujo primeiro membro vem de classe base. +- conventions para um tipo derivado ja enxergavam propriedades herdadas via `type.GetProperties(...)`. +- nao existia metadado no `EntityMap` para declarar que um map derivado depende de um map base. + +Reproducao inicial: + +- foi adicionado um teste expressando `IncludeBase()`; +- antes da implementacao, a suite falhava na compilacao com `CS0103`, pois a API nao existia; +- isso confirmou que o suporte precisava de contrato publico/protegido novo, nao apenas ajuste de registry. + +## Decision + +API escolhida: + +```csharp +protected void IncludeBase() + where TBase : class +``` + +Uso: + +```csharp +public class UserMap : EntityMap +{ + public UserMap() + { + Map(e => e.Id).ToColumn("user_id"); + } +} + +public class AdminUserMap : EntityMap +{ + public AdminUserMap() + { + IncludeBase(); + Map(e => e.Permission).ToColumn("admin_permission"); + } +} +``` + +Motivos: + +- inclusao deliberada, sem heranca magica por reflection; +- baixa complexidade; +- preserva API publica existente e adiciona apenas uma API protegida para autores de maps; +- evita profiles, modos de heranca ou scanning amplo; +- permite diagnostico claro quando a base nao foi registrada. + +Resolucao do mapping base: + +- `IncludeBase()` armazena internamente o tipo base no `EntityMap` derivado; +- o `MappingRegistry` resolve o `IEntityMap` base ja registrado para esse tipo; +- o base map deve ser registrado antes do derived map; +- se o base map nao existir, `AddMap(derived)` falha com `FluentMapConfigurationException`. + +Modelo de composicao: + +```text +maps proprios do derivado +maps explicitos da base incluida, ja compostos recursivamente +``` + +Para multiplos niveis: + +```text +Derived + IncludeBase() + +Intermediate + IncludeBase() +``` + +O resultado efetivo para `Derived` e: + +```text +Derived explicit maps +Intermediate explicit maps +Base explicit maps +``` + +Precedencia final: + +```text +Mapping explicito do derivado + ↓ +Mapping explicito herdado mais proximo + ↓ +Mapping explicito herdado mais distante + ↓ +Convention do tipo consultado + ↓ +Dapper Default +``` + +Overrides: + +- se derivado e base configurarem o mesmo `MemberPath`, o mapping do derivado vence; +- o mapping base sobrescrito nao participa da resolucao de coluna para o tipo derivado; +- a comparacao de override usa `MemberPath`, preservando membros herdados e caminhos aninhados. + +Conflitos: + +- se derivado e base configurarem a mesma coluna para `MemberPath` diferentes, a configuracao do derivado falha cedo; +- conflito respeita case sensitivity pelas regras da Entrega 02; +- conflito real entre maps do core continua sendo `FluentMapConfigurationException`. + +Conventions: + +- conventions continuam registradas por tipo; +- mappings explicitos compostos, incluindo herdados, bloqueiam convention para o mesmo `MemberPath`; +- convention ainda pode resolver propriedades distintas do derivado. + +Validacoes: + +- `TBase` deve ser uma classe base real de `TEntity`; +- incluir o mesmo base type mais de uma vez e invalido; +- base map ausente e invalido no registro do map derivado; +- derived antes de base e invalido, mas pode ser tentado novamente depois que a base for registrada; +- coluna duplicada entre derivado e base e invalida; +- `MemberPath` incompativel continua invalido pela validacao existente. + +## Delivery + +Arquivos alterados: + +- `src/Dapper.FluentMap/Mapping/EntityMap.cs` +- `src/Dapper.FluentMap/MappingRegistry.cs` +- `src/Dapper.FluentMap/MappingConfigurationValidator.cs` +- `test/Dapper.FluentMap.Tests/MappingCompositionTests.cs` +- `test/Dapper.FluentMap.Tests/InheritedMappingTests.cs` +- `docs/sdd/etapa-2/status.md` +- `docs/sdd/etapa-2/decisions.md` +- `docs/sdd/etapa-2/03-inherited-mappings.md` + +Implementacao: + +- adicionado metadado interno `IEntityMapWithIncludedBaseTypes`; +- `EntityMapBase` passou a registrar bases incluidas; +- `IncludeBase()` valida relacao de heranca e duplicidade; +- `MappingRegistry` passou a compor explicit maps do tipo consultado com mapas base incluidos; +- composicao recursiva ignora paths ja definidos pelo tipo mais derivado, implementando override; +- validacao composta detecta conflitos de coluna depois da aplicacao dos overrides. + +Nao implementado: + +- heranca automatica sem `IncludeBase()`; +- multiplos modos de heranca; +- profiles; +- compartilhamento entre tipos nao relacionados; +- suporte novo a materializacao aninhada; +- alteracao funcional no Dommel. + +## Tests + +Testes adicionados cobrem: + +- base mapping simples; +- derived adicionando propriedade propria; +- derived sobrescrevendo mapping base; +- mapping base com convention no derived; +- `MemberPath` herdado e aninhado; +- multiplos niveis de heranca; +- base map inexistente; +- tipo informado que nao e base valido; +- conflito de coluna entre derived e base; +- ordem de registro base antes de derived; +- materializacao real com Dapper e SQLite in-memory. + +## Validation + +Comandos executados durante a entrega: + +- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~IncludedBaseMappingShouldResolveColumnForDerivedEntity"` + - antes da implementacao: falha de compilacao `CS0103` porque `IncludeBase` nao existia; + - depois da implementacao: sucesso, 1 teste aprovado. +- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~InheritedMappingTests"` + - resultado: sucesso, 11 testes aprovados. +- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj` + - resultado: sucesso, 77 testes aprovados. + +- `dotnet restore` + - resultado: sucesso. +- `dotnet build` + - resultado: sucesso, 0 warnings, 0 erros. +- `dotnet test` + - resultado: sucesso, 77 testes aprovados no core e 7 testes aprovados no Dommel. +- `dotnet build --configuration Release` + - resultado: sucesso, 0 warnings, 0 erros. +- `dotnet test --configuration Release` + - resultado: sucesso, 77 testes aprovados no core e 7 testes aprovados no Dommel. + +Pack nao e esperado porque nao houve mudanca de empacotamento, metadados NuGet ou targets. + +## Limitacoes + +- a base deve ser registrada antes do derivado; +- mutacoes diretas nos dicionarios publicos legados continuam fora do modelo de invalidacao segura; +- o suporte inclui apenas explicit mappings de base, nao conventions registradas para o tipo base; +- `IncludeBase()` aceita apenas classe base real, nao interface; +- materializacao aninhada permanece fora do escopo. diff --git a/docs/sdd/etapa-2/decisions.md b/docs/sdd/etapa-2/decisions.md index beb9018..7856ef7 100644 --- a/docs/sdd/etapa-2/decisions.md +++ b/docs/sdd/etapa-2/decisions.md @@ -21,3 +21,14 @@ Registre aqui apenas decisoes que afetem entregas posteriores. - Conflitos de coluna dentro do mesmo entity map do core ou da mesma convention sao invalidos quando mais de uma propriedade pode responder pela mesma coluna, incluindo sobreposicao por case-insensitive. - Implementacoes externas de `IPropertyMap` nao recebem validacao global de conflito de coluna, porque integracoes como Dommel podem reutilizar colunas com semantica adicional propria. - Conflitos entre mapping explicito e convention para a mesma coluna continuam fora do escopo de erro imediato e seguem a precedencia explicito -> convention -> Dapper default. + +## Heranca De Mappings + +- Heranca de mappings e opt-in por `IncludeBase()`; nao ha aplicacao automatica de maps de classes base por reflection. +- O map base deve estar registrado antes do map derivado; a ausencia do base map falha cedo com `FluentMapConfigurationException`. +- A composicao de mappings explicitos para um tipo derivado segue a ordem: mappings proprios do derivado, mappings herdados mais proximos, mappings herdados mais distantes. +- A precedencia final passa a ser: mapping explicito do derivado -> mapping explicito herdado -> convention do tipo consultado -> Dapper default. +- Overrides sao definidos por `MemberPath`: quando derivado e base mapeiam o mesmo path, o derivado vence e o mapping base sobrescrito nao participa da resolucao para o derivado. +- Conflitos de coluna entre mappings explicitos do derivado e mappings herdados de paths diferentes sao invalidos e diagnosticados durante o registro do map derivado. +- `IncludeBase()` aceita apenas classe base real do tipo mapeado; tipos nao relacionados, o proprio tipo e interfaces ficam fora do contrato desta entrega. +- Naming policies futuras devem respeitar a composicao explicita efetiva antes de aplicar conventions ou fallback. diff --git a/docs/sdd/etapa-2/status.md b/docs/sdd/etapa-2/status.md index 434cad7..b5e38fb 100644 --- a/docs/sdd/etapa-2/status.md +++ b/docs/sdd/etapa-2/status.md @@ -4,5 +4,5 @@ |---|---|---| | 01 - MemberPath | Concluido | - | | 02 - Validacao e diagnosticos | Concluido | - | -| 03 - Heranca de mappings | Pendente | - | +| 03 - Heranca de mappings | Concluido | - | | 04 - Naming policies | Pendente | - | diff --git a/src/Dapper.FluentMap/Mapping/EntityMap.cs b/src/Dapper.FluentMap/Mapping/EntityMap.cs index 6fc1ce7..cad7f6d 100644 --- a/src/Dapper.FluentMap/Mapping/EntityMap.cs +++ b/src/Dapper.FluentMap/Mapping/EntityMap.cs @@ -27,12 +27,17 @@ public interface IEntityMap : IEntityMap { } + internal interface IEntityMapWithIncludedBaseTypes + { + IList IncludedBaseTypes { get; } + } + /// /// Serves as the base class for all entity mapping implementations. /// /// The type of the entity. /// The type of the property mapping. - public abstract class EntityMapBase : IEntityMap + public abstract class EntityMapBase : IEntityMap, IEntityMapWithIncludedBaseTypes where TPropertyMap : IPropertyMap { /// @@ -41,6 +46,7 @@ public abstract class EntityMapBase : IEntityMap protected EntityMapBase() { PropertyMaps = new List(); + IncludedBaseTypes = new List(); } /// @@ -48,6 +54,10 @@ protected EntityMapBase() /// public IList PropertyMaps { get; } + IList IEntityMapWithIncludedBaseTypes.IncludedBaseTypes => IncludedBaseTypes; + + private IList IncludedBaseTypes { get; } + /// /// Returns an instance of which can perform custom mapping /// for the specified property on . @@ -65,6 +75,35 @@ protected TPropertyMap Map(Expression> expression) return propertyMap; } + /// + /// Includes the explicit mappings configured for a base entity map. + /// + /// The base entity type whose mappings should be included. + /// + /// when is not a valid base type for + /// or the same base type is included more than once. + /// + protected void IncludeBase() + where TBase : class + { + var baseType = typeof(TBase); + var entityType = typeof(TEntity); + + if (baseType == entityType || !baseType.IsClass || !baseType.IsAssignableFrom(entityType)) + { + throw new FluentMapConfigurationException( + $"Type '{baseType.FullName}' cannot be included as a base mapping for entity '{entityType.FullName}'. The included type must be a base class of the entity."); + } + + if (IncludedBaseTypes.Contains(baseType)) + { + throw new FluentMapConfigurationException( + $"Base mapping for type '{baseType.FullName}' is already included by entity '{entityType.FullName}'."); + } + + IncludedBaseTypes.Add(baseType); + } + /// /// When overridden in a derived class, gets the property mapping for the specified property. /// diff --git a/src/Dapper.FluentMap/MappingConfigurationValidator.cs b/src/Dapper.FluentMap/MappingConfigurationValidator.cs index 723f94a..55ce011 100644 --- a/src/Dapper.FluentMap/MappingConfigurationValidator.cs +++ b/src/Dapper.FluentMap/MappingConfigurationValidator.cs @@ -25,6 +25,27 @@ internal static void ValidateEntityMap(Type entityType, IEntityMap entityMap) ValidateColumnConflicts(entityType, maps, "entity map", entityMap.GetType()); } + internal static void ValidateComposedEntityMap(Type entityType, IEntityMap entityMap, IList propertyMaps) + { + if (entityType == null) + { + throw new ArgumentNullException(nameof(entityType)); + } + + if (entityMap == null) + { + throw new ArgumentNullException(nameof(entityMap)); + } + + if (propertyMaps == null) + { + throw new ArgumentNullException(nameof(propertyMaps)); + } + + var maps = GetEntityMapDescriptors(entityType, propertyMaps, entityMap.GetType(), "composed entity map").ToList(); + ValidateColumnConflicts(entityType, maps, "composed entity map", entityMap.GetType()); + } + internal static void ValidateConvention(Type entityType, Convention convention) { if (entityType == null) @@ -68,9 +89,14 @@ private static IEnumerable GetEntityMapDescriptors(Type entityTyp $"Entity map '{FormatType(entityMap.GetType())}' for entity '{FormatType(entityType)}' returned a null property map collection."); } - foreach (var map in entityMap.PropertyMaps) + return GetEntityMapDescriptors(entityType, entityMap.PropertyMaps, entityMap.GetType(), "entity map"); + } + + private static IEnumerable GetEntityMapDescriptors(Type entityType, IEnumerable propertyMaps, Type sourceType, string sourceKind) + { + foreach (var map in propertyMaps) { - yield return CreateDescriptor(entityType, map, entityMap.GetType(), "entity map", requireEntityCompatibility: true); + yield return CreateDescriptor(entityType, map, sourceType, sourceKind, requireEntityCompatibility: true); } } diff --git a/src/Dapper.FluentMap/MappingRegistry.cs b/src/Dapper.FluentMap/MappingRegistry.cs index 71d593b..85786e0 100644 --- a/src/Dapper.FluentMap/MappingRegistry.cs +++ b/src/Dapper.FluentMap/MappingRegistry.cs @@ -37,6 +37,8 @@ internal void AddEntityMap(IEntityMap mapper) } MappingConfigurationValidator.ValidateEntityMap(type, mapper); + ValidateIncludedBaseMaps(type, mapper); + MappingConfigurationValidator.ValidateComposedEntityMap(type, mapper, ComposeExplicitPropertyMaps(type, mapper)); if (!EntityMaps.TryAdd(type, mapper)) { @@ -156,12 +158,74 @@ private IList GetExplicitPropertyMaps(Type type) { if (EntityMaps.TryGetValue(type, out var entityMap)) { - return entityMap.PropertyMaps; + return ComposeExplicitPropertyMaps(type, entityMap); } return new IPropertyMap[0]; } + private void ValidateIncludedBaseMaps(Type type, IEntityMap entityMap) + { + foreach (var baseType in GetIncludedBaseTypes(entityMap)) + { + if (baseType == type || !baseType.IsClass || !baseType.IsAssignableFrom(type)) + { + throw new FluentMapConfigurationException( + $"Type '{baseType.FullName}' cannot be included as a base mapping for entity '{type.FullName}'. The included type must be a base class of the entity."); + } + + if (!EntityMaps.ContainsKey(baseType)) + { + throw new FluentMapConfigurationException( + $"Entity '{type.FullName}' includes base mapping '{baseType.FullName}', but no entity map has been registered for the base type. Register the base map before the derived map."); + } + } + } + + private IList ComposeExplicitPropertyMaps(Type type, IEntityMap entityMap) + { + var propertyMaps = new List(); + AddPropertyMapsWithOverride(propertyMaps, entityMap.PropertyMaps); + + foreach (var baseType in GetIncludedBaseTypes(entityMap)) + { + if (!EntityMaps.TryGetValue(baseType, out var baseMap)) + { + throw new FluentMapConfigurationException( + $"Entity '{type.FullName}' includes base mapping '{baseType.FullName}', but no entity map has been registered for the base type. Register the base map before the derived map."); + } + + AddPropertyMapsWithOverride(propertyMaps, ComposeExplicitPropertyMaps(baseType, baseMap)); + } + + return propertyMaps; + } + + private static void AddPropertyMapsWithOverride(IList target, IEnumerable maps) + { + foreach (var map in maps) + { + var memberPath = PropertyMapIdentity.GetMemberPath(map); + if (target.Any(existingMap => PropertyMapIdentity.GetMemberPath(existingMap).Equals(memberPath))) + { + continue; + } + + target.Add(map); + } + } + + private static IList GetIncludedBaseTypes(IEntityMap entityMap) + { + var mapWithIncludedBases = entityMap as IEntityMapWithIncludedBaseTypes; + if (mapWithIncludedBases == null) + { + return new Type[0]; + } + + return mapWithIncludedBases.IncludedBaseTypes; + } + private PropertyInfo ResolveConventionPropertyInfo(Type type, string columnName) { return ResolveConventionPropertyInfo(type, columnName, new IPropertyMap[0]); diff --git a/test/Dapper.FluentMap.Tests/InheritedMappingTests.cs b/test/Dapper.FluentMap.Tests/InheritedMappingTests.cs new file mode 100644 index 0000000..dc8bca7 --- /dev/null +++ b/test/Dapper.FluentMap.Tests/InheritedMappingTests.cs @@ -0,0 +1,530 @@ +using System; +using Dapper; +using Dapper.FluentMap.Conventions; +using Dapper.FluentMap.Mapping; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class InheritedMappingTests + { + [Fact] + public void IncludedBaseMappingShouldResolveInheritedProperty() + { + PreTest(typeof(SimpleBaseUser), typeof(SimpleAdminUser)); + + FluentMapper.Initialize(c => + { + c.AddMap(new SimpleBaseUserMap()); + c.AddMap(new SimpleAdminUserMap()); + }); + + var member = SqlMapper.GetTypeMap(typeof(SimpleAdminUser)).GetMember("user_id"); + + Assert.NotNull(member); + Assert.Equal(typeof(SimpleBaseUser).GetProperty(nameof(SimpleBaseUser.Id)), member.Property); + } + + [Fact] + public void DerivedMappingShouldResolveOwnPropertyWithIncludedBase() + { + PreTest(typeof(DerivedPropertyBaseUser), typeof(DerivedPropertyAdminUser)); + + FluentMapper.Initialize(c => + { + c.AddMap(new DerivedPropertyBaseUserMap()); + c.AddMap(new DerivedPropertyAdminUserMap()); + }); + + var baseMember = SqlMapper.GetTypeMap(typeof(DerivedPropertyAdminUser)).GetMember("user_id"); + var derivedMember = SqlMapper.GetTypeMap(typeof(DerivedPropertyAdminUser)).GetMember("admin_permission"); + + Assert.NotNull(baseMember); + Assert.NotNull(derivedMember); + Assert.Equal(typeof(DerivedPropertyBaseUser).GetProperty(nameof(DerivedPropertyBaseUser.Id)), baseMember.Property); + Assert.Equal(typeof(DerivedPropertyAdminUser).GetProperty(nameof(DerivedPropertyAdminUser.Permission)), derivedMember.Property); + } + + [Fact] + public void DerivedMappingShouldOverrideIncludedBaseForSameMemberPath() + { + PreTest(typeof(OverrideBaseUser), typeof(OverrideAdminUser)); + + FluentMapper.Initialize(c => + { + c.AddMap(new OverrideBaseUserMap()); + c.AddMap(new OverrideAdminUserMap()); + }); + + var derivedMember = SqlMapper.GetTypeMap(typeof(OverrideAdminUser)).GetMember("admin_id"); + var baseMember = SqlMapper.GetTypeMap(typeof(OverrideAdminUser)).GetMember("user_id"); + + Assert.NotNull(derivedMember); + Assert.Null(baseMember); + Assert.Equal(typeof(OverrideBaseUser).GetProperty(nameof(OverrideBaseUser.Id)), derivedMember.Property); + } + + [Fact] + public void IncludedBaseMappingShouldTakePrecedenceOverConventionForSameMemberPath() + { + PreTest(typeof(ConventionBaseUser), typeof(ConventionAdminUser)); + + FluentMapper.Initialize(c => + { + c.AddMap(new ConventionBaseUserMap()); + c.AddMap(new ConventionAdminUserMap()); + c.AddConvention().ForEntity(); + }); + + var inheritedExplicitMember = SqlMapper.GetTypeMap(typeof(ConventionAdminUser)).GetMember("user_id"); + var conventionForInheritedMember = SqlMapper.GetTypeMap(typeof(ConventionAdminUser)).GetMember("colId"); + var conventionForDerivedMember = SqlMapper.GetTypeMap(typeof(ConventionAdminUser)).GetMember("colPermission"); + + Assert.NotNull(inheritedExplicitMember); + Assert.Null(conventionForInheritedMember); + Assert.NotNull(conventionForDerivedMember); + Assert.Equal(typeof(ConventionBaseUser).GetProperty(nameof(ConventionBaseUser.Id)), inheritedExplicitMember.Property); + Assert.Equal(typeof(ConventionAdminUser).GetProperty(nameof(ConventionAdminUser.Permission)), conventionForDerivedMember.Property); + } + + [Fact] + public void IncludedBaseMappingShouldPreserveInheritedMemberPath() + { + PreTest(typeof(MemberPathBaseUser), typeof(MemberPathAdminUser)); + + FluentMapper.Initialize(c => + { + c.AddMap(new MemberPathBaseUserMap()); + c.AddMap(new MemberPathAdminUserMap()); + }); + + var member = SqlMapper.GetTypeMap(typeof(MemberPathAdminUser)).GetMember("rank_level"); + + Assert.NotNull(member); + Assert.Equal(typeof(InheritedRankInfo).GetProperty(nameof(InheritedRankInfo.Level)), member.Property); + } + + [Fact] + public void MultipleInheritanceLevelsShouldComposeNearestMappingsBeforeBaseMappings() + { + PreTest(typeof(MultiLevelBaseUser), typeof(MultiLevelStaffUser), typeof(MultiLevelAdminUser)); + + FluentMapper.Initialize(c => + { + c.AddMap(new MultiLevelBaseUserMap()); + c.AddMap(new MultiLevelStaffUserMap()); + c.AddMap(new MultiLevelAdminUserMap()); + }); + + var baseMember = SqlMapper.GetTypeMap(typeof(MultiLevelAdminUser)).GetMember("user_id"); + var intermediateMember = SqlMapper.GetTypeMap(typeof(MultiLevelAdminUser)).GetMember("staff_code"); + var derivedMember = SqlMapper.GetTypeMap(typeof(MultiLevelAdminUser)).GetMember("admin_permission"); + + Assert.NotNull(baseMember); + Assert.NotNull(intermediateMember); + Assert.NotNull(derivedMember); + Assert.Equal(typeof(MultiLevelBaseUser).GetProperty(nameof(MultiLevelBaseUser.Id)), baseMember.Property); + Assert.Equal(typeof(MultiLevelStaffUser).GetProperty(nameof(MultiLevelStaffUser.StaffCode)), intermediateMember.Property); + Assert.Equal(typeof(MultiLevelAdminUser).GetProperty(nameof(MultiLevelAdminUser.Permission)), derivedMember.Property); + } + + [Fact] + public void MissingBaseMapShouldThrowConfigurationException() + { + PreTest(typeof(MissingBaseUser), typeof(MissingBaseAdminUser)); + + var exception = Assert.Throws(() => + FluentMapper.Initialize(c => c.AddMap(new MissingBaseAdminUserMap()))); + + Assert.Contains(typeof(MissingBaseAdminUser).FullName, exception.Message); + Assert.Contains(typeof(MissingBaseUser).FullName, exception.Message); + Assert.Contains("Register the base map before the derived map", exception.Message); + } + + [Fact] + public void InvalidBaseTypeShouldThrowConfigurationException() + { + var exception = Assert.Throws(() => new InvalidBaseAdminUserMap()); + + Assert.Contains(typeof(UnrelatedUser).FullName, exception.Message); + Assert.Contains(typeof(InvalidBaseAdminUser).FullName, exception.Message); + Assert.Contains("base class", exception.Message); + } + + [Fact] + public void ColumnConflictBetweenDerivedAndIncludedBaseShouldThrowConfigurationException() + { + PreTest(typeof(ConflictBaseUser), typeof(ConflictAdminUser)); + + var exception = Assert.Throws(() => + FluentMapper.Initialize(c => + { + c.AddMap(new ConflictBaseUserMap()); + c.AddMap(new ConflictAdminUserMap()); + })); + + Assert.Contains("shared_column", exception.Message); + Assert.Contains(nameof(ConflictBaseUser.Id), exception.Message); + Assert.Contains(nameof(ConflictAdminUser.Permission), exception.Message); + Assert.Contains(typeof(ConflictAdminUser).FullName, exception.Message); + } + + [Fact] + public void DerivedMapMustBeRegisteredAfterIncludedBaseMap() + { + PreTest(typeof(RegistrationBaseUser), typeof(RegistrationAdminUser)); + + Assert.Throws(() => + FluentMapper.Initialize(c => c.AddMap(new RegistrationAdminUserMap()))); + + FluentMapper.Initialize(c => + { + c.AddMap(new RegistrationBaseUserMap()); + c.AddMap(new RegistrationAdminUserMap()); + }); + + var member = SqlMapper.GetTypeMap(typeof(RegistrationAdminUser)).GetMember("user_id"); + + Assert.NotNull(member); + Assert.Equal(typeof(RegistrationBaseUser).GetProperty(nameof(RegistrationBaseUser.Id)), member.Property); + } + + [Fact] + [Trait("Category", "Integration")] + public void IncludedBaseMappingShouldMaterializeWithDapper() + { + PreTest(typeof(IntegrationBaseUser), typeof(IntegrationAdminUser)); + + try + { + FluentMapper.Initialize(c => + { + c.AddMap(new IntegrationBaseUserMap()); + c.AddMap(new IntegrationAdminUserMap()); + }); + + using (var connection = OpenConnection()) + { + var entity = connection.QuerySingle( + "SELECT 42 AS user_id, 'deploy' AS admin_permission;"); + + Assert.Equal(42, entity.Id); + Assert.Equal("deploy", entity.Permission); + } + } + finally + { + PreTest(typeof(IntegrationBaseUser), typeof(IntegrationAdminUser)); + } + } + + private static SqliteConnection OpenConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + + private static void PreTest(params Type[] types) + { + FluentMapper.Reset(types); + } + + private class SimpleBaseUser + { + public int Id { get; set; } + } + + private class SimpleAdminUser : SimpleBaseUser + { + } + + private class SimpleBaseUserMap : EntityMap + { + public SimpleBaseUserMap() + { + Map(e => e.Id).ToColumn("user_id"); + } + } + + private class SimpleAdminUserMap : EntityMap + { + public SimpleAdminUserMap() + { + IncludeBase(); + } + } + + private class DerivedPropertyBaseUser + { + public int Id { get; set; } + } + + private class DerivedPropertyAdminUser : DerivedPropertyBaseUser + { + public string Permission { get; set; } + } + + private class DerivedPropertyBaseUserMap : EntityMap + { + public DerivedPropertyBaseUserMap() + { + Map(e => e.Id).ToColumn("user_id"); + } + } + + private class DerivedPropertyAdminUserMap : EntityMap + { + public DerivedPropertyAdminUserMap() + { + IncludeBase(); + Map(e => e.Permission).ToColumn("admin_permission"); + } + } + + private class OverrideBaseUser + { + public int Id { get; set; } + } + + private class OverrideAdminUser : OverrideBaseUser + { + } + + private class OverrideBaseUserMap : EntityMap + { + public OverrideBaseUserMap() + { + Map(e => e.Id).ToColumn("user_id"); + } + } + + private class OverrideAdminUserMap : EntityMap + { + public OverrideAdminUserMap() + { + IncludeBase(); + Map(e => e.Id).ToColumn("admin_id"); + } + } + + private class ConventionBaseUser + { + public int Id { get; set; } + } + + private class ConventionAdminUser : ConventionBaseUser + { + public string Permission { get; set; } + } + + private class ConventionBaseUserMap : EntityMap + { + public ConventionBaseUserMap() + { + Map(e => e.Id).ToColumn("user_id"); + } + } + + private class ConventionAdminUserMap : EntityMap + { + public ConventionAdminUserMap() + { + IncludeBase(); + } + } + + private class MemberPathBaseUser + { + public InheritedRankInfo Rank { get; set; } + } + + private class MemberPathAdminUser : MemberPathBaseUser + { + } + + private class InheritedRankInfo + { + public int Level { get; set; } + } + + private class MemberPathBaseUserMap : EntityMap + { + public MemberPathBaseUserMap() + { + Map(e => e.Rank.Level).ToColumn("rank_level"); + } + } + + private class MemberPathAdminUserMap : EntityMap + { + public MemberPathAdminUserMap() + { + IncludeBase(); + } + } + + private class MultiLevelBaseUser + { + public int Id { get; set; } + } + + private class MultiLevelStaffUser : MultiLevelBaseUser + { + public string StaffCode { get; set; } + } + + private class MultiLevelAdminUser : MultiLevelStaffUser + { + public string Permission { get; set; } + } + + private class MultiLevelBaseUserMap : EntityMap + { + public MultiLevelBaseUserMap() + { + Map(e => e.Id).ToColumn("user_id"); + } + } + + private class MultiLevelStaffUserMap : EntityMap + { + public MultiLevelStaffUserMap() + { + IncludeBase(); + Map(e => e.StaffCode).ToColumn("staff_code"); + } + } + + private class MultiLevelAdminUserMap : EntityMap + { + public MultiLevelAdminUserMap() + { + IncludeBase(); + Map(e => e.Permission).ToColumn("admin_permission"); + } + } + + private class MissingBaseUser + { + public int Id { get; set; } + } + + private class MissingBaseAdminUser : MissingBaseUser + { + } + + private class MissingBaseAdminUserMap : EntityMap + { + public MissingBaseAdminUserMap() + { + IncludeBase(); + } + } + + private class InvalidBaseAdminUser + { + } + + private class UnrelatedUser + { + } + + private class InvalidBaseAdminUserMap : EntityMap + { + public InvalidBaseAdminUserMap() + { + IncludeBase(); + } + } + + private class ConflictBaseUser + { + public int Id { get; set; } + } + + private class ConflictAdminUser : ConflictBaseUser + { + public string Permission { get; set; } + } + + private class ConflictBaseUserMap : EntityMap + { + public ConflictBaseUserMap() + { + Map(e => e.Id).ToColumn("shared_column"); + } + } + + private class ConflictAdminUserMap : EntityMap + { + public ConflictAdminUserMap() + { + IncludeBase(); + Map(e => e.Permission).ToColumn("shared_column"); + } + } + + private class RegistrationBaseUser + { + public int Id { get; set; } + } + + private class RegistrationAdminUser : RegistrationBaseUser + { + } + + private class RegistrationBaseUserMap : EntityMap + { + public RegistrationBaseUserMap() + { + Map(e => e.Id).ToColumn("user_id"); + } + } + + private class RegistrationAdminUserMap : EntityMap + { + public RegistrationAdminUserMap() + { + IncludeBase(); + } + } + + private class IntegrationBaseUser + { + public int Id { get; set; } + } + + private class IntegrationAdminUser : IntegrationBaseUser + { + public string Permission { get; set; } + } + + private class IntegrationBaseUserMap : EntityMap + { + public IntegrationBaseUserMap() + { + Map(e => e.Id).ToColumn("user_id"); + } + } + + private class IntegrationAdminUserMap : EntityMap + { + public IntegrationAdminUserMap() + { + IncludeBase(); + Map(e => e.Permission).ToColumn("admin_permission"); + } + } + + private class PrefixConvention : Convention + { + public PrefixConvention() + { + Properties() + .Configure(c => c.HasPrefix("col")); + } + } + } +} diff --git a/test/Dapper.FluentMap.Tests/MappingCompositionTests.cs b/test/Dapper.FluentMap.Tests/MappingCompositionTests.cs index 8e17055..b31b61a 100644 --- a/test/Dapper.FluentMap.Tests/MappingCompositionTests.cs +++ b/test/Dapper.FluentMap.Tests/MappingCompositionTests.cs @@ -20,6 +20,23 @@ public void ExplicitMappingShouldResolveColumn() Assert.Equal(typeof(ExplicitOnlyEntity).GetProperty(nameof(ExplicitOnlyEntity.Id)), member.Property); } + [Fact] + public void IncludedBaseMappingShouldResolveColumnForDerivedEntity() + { + PreTest(typeof(BaseUser), typeof(AdminUser)); + + FluentMapper.Initialize(c => + { + c.AddMap(new BaseUserMap()); + c.AddMap(new AdminUserMap()); + }); + + var member = SqlMapper.GetTypeMap(typeof(AdminUser)).GetMember("user_id"); + + Assert.NotNull(member); + Assert.Equal(typeof(BaseUser).GetProperty(nameof(BaseUser.Id)), member.Property); + } + [Fact] public void ConventionShouldResolveColumn() { @@ -181,6 +198,34 @@ public ExplicitOnlyMap() } } + private class BaseUser + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private class AdminUser : BaseUser + { + public string Permission { get; set; } + } + + private class BaseUserMap : EntityMap + { + public BaseUserMap() + { + Map(e => e.Id).ToColumn("user_id"); + } + } + + private class AdminUserMap : EntityMap + { + public AdminUserMap() + { + IncludeBase(); + } + } + private class ConventionOnlyEntity { public string Name { get; set; } From 2bfad1ea8be960a00e3490de51cf73d222fdd42e Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sun, 26 Jul 2026 09:18:27 -0300 Subject: [PATCH 04/20] feat: add configurable naming policies --- README.md | 9 + docs/sdd/etapa-2/04-naming-policies.md | 262 ++++++++++ docs/sdd/etapa-2/decisions.md | 12 + docs/sdd/etapa-2/status.md | 8 +- .../Configuration/FluentMapConfiguration.cs | 39 ++ .../Conventions/NamingPolicyConvention.cs | 20 + src/Dapper.FluentMap/Naming/NamingPolicy.cs | 210 ++++++++ .../NamingPolicyTests.cs | 453 ++++++++++++++++++ 8 files changed, 1009 insertions(+), 4 deletions(-) create mode 100644 docs/sdd/etapa-2/04-naming-policies.md create mode 100644 src/Dapper.FluentMap/Conventions/NamingPolicyConvention.cs create mode 100644 src/Dapper.FluentMap/Naming/NamingPolicy.cs create mode 100644 test/Dapper.FluentMap.Tests/NamingPolicyTests.cs diff --git a/README.md b/README.md index 4f6f0b7..5c3adf1 100644 --- a/README.md +++ b/README.md @@ -165,3 +165,12 @@ FluentMapper.Initialize(config => - Principais decisoes: `FluentMapper` permanece como fachada publica; `MappingRegistry` e o dono interno de mappings/cache; `SqlMapper.SetTypeMap` continua como integracao global necessaria com o Dapper. - Dividas transferidas: dicionarios publicos mutaveis preservados por compatibilidade, consumo direto pelo Dommel, paralelismo da suite ainda desabilitado, MemberPath/nested objects/Value Objects fora desta etapa. - Relatorios: `docs/sdd/etapa-1/01-reflection-helper.md`, `docs/sdd/etapa-1/02-mapping-composition.md`, `docs/sdd/etapa-1/03-dapper-integration-tests.md`, `docs/sdd/etapa-1/04-mapping-registry-cache.md`. + +## Resultado da Etapa 2 + +- Capacidades estabilizadas: `MemberPath` para identidade interna de propriedades, validacao fail-fast com `FluentMapConfigurationException`, heranca opt-in por `IncludeBase()` e naming policies configuraveis via `UseNamingPolicy(...)`. +- Precedencia consolidada: mapping explicito do derivado, mapping explicito herdado mais proximo, mapping explicito herdado mais distante, convention/naming policy do tipo consultado e fallback do Dapper. +- APIs publicas adicionadas: `Dapper.FluentMap.FluentMapConfigurationException`, `EntityMap.IncludeBase()`, `Dapper.FluentMap.Naming.NamingPolicy` e `FluentMapConfiguration.UseNamingPolicy(...)`. +- Naming policies implementadas: `SnakeCase`, `Prefix`, `Suffix`, `Custom` e composicao por `Then`, `WithPrefix` e `WithSuffix`, sem alterar `DefaultTypeMap.MatchNamesWithUnderscores`. +- Dividas adiadas: nested object materialization, Value Objects complexos, constructor/record mapping, multiple mapping profiles, Roslyn analyzers, source generators e AOT/trimming. +- Relatorios: `docs/sdd/etapa-2/01-member-path.md`, `docs/sdd/etapa-2/02-configuration-validation.md`, `docs/sdd/etapa-2/03-inherited-mappings.md`, `docs/sdd/etapa-2/04-naming-policies.md`. diff --git a/docs/sdd/etapa-2/04-naming-policies.md b/docs/sdd/etapa-2/04-naming-policies.md new file mode 100644 index 0000000..78cab5e --- /dev/null +++ b/docs/sdd/etapa-2/04-naming-policies.md @@ -0,0 +1,262 @@ +# 04 - Naming Policies + +## Specification + +Adicionar uma API clara e reutilizavel para transformar nomes de membros em nomes de colunas sem criar um segundo pipeline de conventions. + +Casos tratados: + +- `CustomerId -> customer_id`; +- `FirstName -> first_name`; +- `Id -> customer_id`; +- `Name -> usr_name`; +- prefix; +- suffix; +- transformacao customizada; +- composicao com mappings explicitos, mappings herdados, conventions e fallback do Dapper. + +Fora do objetivo: + +- reproduzir apenas `DefaultTypeMap.MatchNamesWithUnderscores`; +- criar dezenas de estilos de nomes; +- introduzir profiles; +- alterar estado global do Dapper como efeito colateral; +- declarar suporte a materializacao aninhada. + +## Discovery + +Arquivos analisados: + +- `AGENTS.md` +- `docs/sdd/etapa-1/README.md` +- `docs/sdd/etapa-1/decisions.md` +- `docs/sdd/etapa-1/04-mapping-registry-cache.md` +- `docs/sdd/etapa-2/README.md` +- `docs/sdd/etapa-2/status.md` +- `docs/sdd/etapa-2/decisions.md` +- `docs/sdd/etapa-2/01-member-path.md` +- `docs/sdd/etapa-2/02-configuration-validation.md` +- `docs/sdd/etapa-2/03-inherited-mappings.md` +- `README.md` +- `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs` +- `src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs` +- `src/Dapper.FluentMap/Conventions/Convention.cs` +- `src/Dapper.FluentMap/Conventions/PropertyConventionConfiguration.cs` +- `src/Dapper.FluentMap/Conventions/ConventionPropertyConfiguration.cs` +- `src/Dapper.FluentMap/MappingRegistry.cs` +- `src/Dapper.FluentMap/MappingConfigurationValidator.cs` +- testes de composition, inherited mappings, registry e Dapper integration. + +Recursos existentes: + +- `Convention` ja registra `PropertyMap` por entidade. +- `ConventionPropertyConfiguration.HasPrefix(...)` ja permite prefix simples. +- `ConventionPropertyConfiguration.Transform(Func)` ja permite transformacao customizada. +- `ConventionPropertyConfiguration.IsCaseInsensitive()` ja governa comparacao de coluna. +- `FluentConventionConfiguration` ja faz scanning das propriedades, cria `PropertyMap` e chama a validacao. +- `MappingRegistry` ja centraliza explicit mappings, conventions, fallback, cache e instalacao de type maps no Dapper. + +Limitacoes encontradas: + +- para usar uma transformacao simples, o consumidor precisava criar uma classe `Convention` dedicada; +- nao havia built-in para snake_case; +- nao havia built-in direto para suffix; +- prefix e transformacao existiam, mas nao havia um modelo declarativo e composavel de policy; +- o suporte nativo `DefaultTypeMap.MatchNamesWithUnderscores` e um flag estatico global do Dapper e cobre apenas matching underscore, sem prefix, suffix ou custom transform. + +Comportamento nativo do Dapper verificado: + +- `DefaultTypeMap.MatchNamesWithUnderscores = false` nao mapeia `customer_id` para `CustomerId` pelo `DefaultTypeMap`; +- `DefaultTypeMap.MatchNamesWithUnderscores = true` passa a mapear `customer_id` para `CustomerId`; +- o flag e global e foi restaurado no teste; +- a nova API nao altera esse flag. + +## Decision + +Modelo escolhido: + +```csharp +public sealed class NamingPolicy +``` + +com API: + +```csharp +NamingPolicy.Identity +NamingPolicy.SnakeCase +NamingPolicy.Prefix(string prefix) +NamingPolicy.Suffix(string suffix) +NamingPolicy.Custom(Func transformer) + +policy.Then(...) +policy.WithPrefix(...) +policy.WithSuffix(...) +policy.GetColumnName(...) +``` + +Registro: + +```csharp +FluentMapper.Initialize(c => +{ + c.UseNamingPolicy(NamingPolicy.SnakeCase) + .ForEntity(); + + c.UseNamingPolicy(NamingPolicy.SnakeCase.WithPrefix("usr_")) + .ForEntity(); +}); +``` + +Custom: + +```csharp +c.UseNamingPolicy(name => "x_" + name.ToLowerInvariant()) + .ForEntity(); +``` + +Motivos: + +- um delegate e suficiente para a execucao; +- uma classe pequena permite built-ins e composicao sem introduzir interface publica prematura; +- `Func` preserva o mesmo nivel funcional que a convention atual; +- `MemberPath` nao foi exposto na API porque conventions atuais operam sobre propriedades simples do tipo consultado e a etapa nao implementa materializacao aninhada; +- futuras etapas podem adicionar overload baseado em caminho se houver suporte real ponta a ponta. + +Integracao com conventions: + +- `UseNamingPolicy(...)` cria uma convention interna (`NamingPolicyConvention`); +- a convention interna usa `Properties().Configure(c => c.Transform(...))`; +- `UseNamingPolicy(...)` retorna `FluentConventionConfiguration`, portanto usa os mesmos `.ForEntity()`, `.ForEntitiesInAssembly(...)` e `.ForEntitiesInCurrentAssembly(...)`; +- nao ha storage global novo fora do `MappingRegistry`; +- nao ha alteracao silenciosa em `DefaultTypeMap.MatchNamesWithUnderscores`. + +Built-ins implementados: + +- `SnakeCase`; +- `Prefix`; +- `Suffix`; +- `Custom`; +- composicao via `Then`, `WithPrefix` e `WithSuffix`. + +Precedencia consolidada: + +```text +Mapping explicito do derivado + | + v +Mapping explicito herdado mais proximo + | + v +Mapping explicito herdado mais distante + | + v +Convention / Naming Policy do tipo consultado + | + v +Dapper Default +``` + +Consequencia: + +- explicit mapping sempre vence naming policy; +- inherited explicit mapping vence naming policy; +- naming policy e convention ficam no mesmo nivel e seguem a ordem de registro entre conventions; +- fallback do Dapper permanece disponivel quando nada no FluentMap resolve a coluna. + +## Delivery + +Arquivos adicionados: + +- `src/Dapper.FluentMap/Naming/NamingPolicy.cs` +- `src/Dapper.FluentMap/Conventions/NamingPolicyConvention.cs` +- `test/Dapper.FluentMap.Tests/NamingPolicyTests.cs` +- `docs/sdd/etapa-2/04-naming-policies.md` + +Arquivos alterados: + +- `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs` +- `docs/sdd/etapa-2/decisions.md` +- `docs/sdd/etapa-2/status.md` +- `README.md` + +Implementacao: + +- `NamingPolicy` encapsula uma funcao de transformacao imutavel; +- `SnakeCase` transforma PascalCase/camelCase em snake_case com tratamento basico de siglas; +- `Prefix` e `Suffix` adicionam texto antes/depois do nome gerado; +- `Custom` aceita `Func`; +- `Then`, `WithPrefix` e `WithSuffix` permitem compor policies; +- `UseNamingPolicy(NamingPolicy, bool caseSensitive = true)` registra a policy; +- `UseNamingPolicy(Func, bool caseSensitive = true)` e atalho para custom; +- a convention interna produz `PropertyMap` e passa pelas validacoes existentes. + +Cache e performance: + +- a transformacao ocorre no momento de `ForEntity(...)`, junto com o mapeamento de convention existente; +- os `PropertyMap` resultantes armazenam o nome de coluna ja transformado; +- a resolucao em runtime continua usando o cache estruturado do `MappingRegistry`; +- a chave de cache nao mudou: tipo, nome de coluna ordinal e estrategia (`FluentMap` ou `ConventionOnly`); +- mudancas feitas por `UseNamingPolicy(...).ForEntity(...)` invalidam o cache do tipo pelo mesmo caminho de `AddConvention`. + +## Tests + +Testes adicionados cobrem: + +- sem policy, preservando fallback do Dapper; +- snake_case; +- prefix composavel; +- suffix composavel; +- transformer customizado; +- explicit mapping maior que policy; +- inherited mapping maior que policy; +- policy junto com convention; +- case sensitivity; +- mesma policy aplicada em tipos diferentes; +- policy invalida retornando coluna nula; +- materializacao real com Dapper e SQLite in-memory; +- confirmacao de que `UseNamingPolicy` nao altera `DefaultTypeMap.MatchNamesWithUnderscores`; +- caracterizacao do comportamento nativo de `DefaultTypeMap.MatchNamesWithUnderscores`. + +## Validation + +Comandos executados durante a entrega: + +- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~NamingPolicyTests"` + - resultado: sucesso, 14 testes aprovados. +- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~MappingCompositionTests|FullyQualifiedName~InheritedMappingTests|FullyQualifiedName~DapperIntegrationTests|FullyQualifiedName~NamingPolicyTests"` + - resultado: sucesso, 42 testes aprovados. + +Validacao final completa registrada apos execucao: + +- `dotnet restore` + - resultado: sucesso. +- `dotnet build` + - resultado: sucesso, 0 warnings, 0 erros. +- `dotnet test` + - resultado: sucesso, 91 testes aprovados no core e 7 testes aprovados no Dommel. +- `dotnet build --configuration Release` + - resultado: sucesso, 0 warnings, 0 erros. +- `dotnet test --configuration Release` + - resultado: sucesso, 91 testes aprovados no core e 7 testes aprovados no Dommel. + +## Encerramento Da Etapa 2 + +Capacidades estabilizadas: + +- identidade interna de membros com `MemberPath`; +- validacao fail-fast e diagnosticos estruturados; +- heranca opt-in de mappings por `IncludeBase()`; +- naming policies declarativas e composaveis; +- precedencia consolidada entre mapping explicito, mapping herdado, convention/naming policy e fallback do Dapper. + +Dividas transferidas: + +- nested object materialization; +- Value Objects complexos; +- constructor/record mapping; +- multiple mapping profiles; +- Roslyn analyzers; +- source generators; +- AOT/trimming. + +Pack nao e esperado porque nao houve mudanca de empacotamento, metadados NuGet ou targets. diff --git a/docs/sdd/etapa-2/decisions.md b/docs/sdd/etapa-2/decisions.md index 7856ef7..52ef523 100644 --- a/docs/sdd/etapa-2/decisions.md +++ b/docs/sdd/etapa-2/decisions.md @@ -32,3 +32,15 @@ Registre aqui apenas decisoes que afetem entregas posteriores. - Conflitos de coluna entre mappings explicitos do derivado e mappings herdados de paths diferentes sao invalidos e diagnosticados durante o registro do map derivado. - `IncludeBase()` aceita apenas classe base real do tipo mapeado; tipos nao relacionados, o proprio tipo e interfaces ficam fora do contrato desta entrega. - Naming policies futuras devem respeitar a composicao explicita efetiva antes de aplicar conventions ou fallback. + +## Naming Policies + +- Naming policy e integrada ao mecanismo existente de conventions; nao ha segundo pipeline de resolucao. +- A API publica usa `NamingPolicy`, uma abstracao leve baseada em delegate, em vez de uma interface publica prematura. +- `FluentMapConfiguration.UseNamingPolicy(...)` retorna `FluentConventionConfiguration`, preservando `.ForEntity()`, `.ForEntitiesInAssembly(...)` e `.ForEntitiesInCurrentAssembly(...)`. +- Built-ins adicionados: `SnakeCase`, `Prefix`, `Suffix` e `Custom`, com composicao por `Then`, `WithPrefix` e `WithSuffix`. +- `SnakeCase` nao altera `DefaultTypeMap.MatchNamesWithUnderscores`; a policy gera `PropertyMap` dentro do FluentMap e evita efeito global silencioso no Dapper. +- A precedencia consolidada e: mapping explicito do derivado -> mapping explicito herdado mais proximo -> mapping explicito herdado mais distante -> convention/naming policy do tipo consultado -> Dapper default. +- Naming policy e convention compartilham o mesmo nivel de precedencia e seguem a ordem de registro entre conventions. +- Transformacao baseada em `MemberPath` completo continua fora do contrato publico, pois a etapa nao implementa materializacao aninhada. +- Invalid policy que produz coluna nula ou vazia falha cedo com `FluentMapConfigurationException` durante a configuracao da entidade. diff --git a/docs/sdd/etapa-2/status.md b/docs/sdd/etapa-2/status.md index b5e38fb..7b79d5a 100644 --- a/docs/sdd/etapa-2/status.md +++ b/docs/sdd/etapa-2/status.md @@ -2,7 +2,7 @@ | Entrega | Status | Commit | |---|---|---| -| 01 - MemberPath | Concluido | - | -| 02 - Validacao e diagnosticos | Concluido | - | -| 03 - Heranca de mappings | Concluido | - | -| 04 - Naming policies | Pendente | - | +| 01 - MemberPath | Concluido | 9a91299 | +| 02 - Validacao e diagnosticos | Concluido | 8611362 | +| 03 - Heranca de mappings | Concluido | 5735b69 | +| 04 - Naming policies | Concluido | feat: add configurable naming policies | diff --git a/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs b/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs index df60430..a36a393 100644 --- a/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs +++ b/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs @@ -3,6 +3,7 @@ using System.ComponentModel; using Dapper.FluentMap.Conventions; using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.Naming; namespace Dapper.FluentMap.Configuration { @@ -42,6 +43,44 @@ public void AddMap(IEntityMap mapper) where TEntity : class return new FluentConventionConfiguration(new TConvention()); } + /// + /// Adds a naming policy to the configuration of Dapper.FluentMap. + /// + /// The naming policy used to transform member names into column names. + /// A value indicating whether the generated column name mappings should be case sensitive. + /// + /// An instance of + /// which allows configuration of the naming policy for entities. + /// + public FluentConventionConfiguration UseNamingPolicy(NamingPolicy namingPolicy, bool caseSensitive = true) + { + if (namingPolicy == null) + { + throw new ArgumentNullException(nameof(namingPolicy)); + } + + return new FluentConventionConfiguration(new NamingPolicyConvention(namingPolicy, caseSensitive)); + } + + /// + /// Adds a custom naming policy to the configuration of Dapper.FluentMap. + /// + /// A function that receives a member name and returns a column name. + /// A value indicating whether the generated column name mappings should be case sensitive. + /// + /// An instance of + /// which allows configuration of the naming policy for entities. + /// + public FluentConventionConfiguration UseNamingPolicy(Func transformer, bool caseSensitive = true) + { + if (transformer == null) + { + throw new ArgumentNullException(nameof(transformer)); + } + + return UseNamingPolicy(NamingPolicy.Custom(transformer), caseSensitive); + } + #region EditorBrowsableStates /// [EditorBrowsable(EditorBrowsableState.Never)] diff --git a/src/Dapper.FluentMap/Conventions/NamingPolicyConvention.cs b/src/Dapper.FluentMap/Conventions/NamingPolicyConvention.cs new file mode 100644 index 0000000..9202dc8 --- /dev/null +++ b/src/Dapper.FluentMap/Conventions/NamingPolicyConvention.cs @@ -0,0 +1,20 @@ +using Dapper.FluentMap.Naming; + +namespace Dapper.FluentMap.Conventions +{ + internal sealed class NamingPolicyConvention : Convention + { + internal NamingPolicyConvention(NamingPolicy namingPolicy, bool caseSensitive) + { + Properties() + .Configure(c => + { + c.Transform(namingPolicy.GetColumnName); + if (!caseSensitive) + { + c.IsCaseInsensitive(); + } + }); + } + } +} diff --git a/src/Dapper.FluentMap/Naming/NamingPolicy.cs b/src/Dapper.FluentMap/Naming/NamingPolicy.cs new file mode 100644 index 0000000..cb7773e --- /dev/null +++ b/src/Dapper.FluentMap/Naming/NamingPolicy.cs @@ -0,0 +1,210 @@ +using System; +using System.ComponentModel; +using System.Globalization; +using System.Text; + +namespace Dapper.FluentMap.Naming +{ + /// + /// Defines a reusable policy for transforming member names into database column names. + /// + public sealed class NamingPolicy + { + private readonly Func _transformer; + + private NamingPolicy(Func transformer) + { + if (transformer == null) + { + throw new ArgumentNullException(nameof(transformer)); + } + + _transformer = transformer; + } + + /// + /// Gets a policy that preserves member names unchanged. + /// + public static NamingPolicy Identity { get; } = new NamingPolicy(name => name); + + /// + /// Gets a policy that converts PascalCase or camelCase member names to snake_case column names. + /// + public static NamingPolicy SnakeCase { get; } = new NamingPolicy(ToSnakeCase); + + /// + /// Creates a policy that prepends the specified prefix to member names. + /// + /// The prefix to add to the generated column name. + /// A naming policy that adds . + public static NamingPolicy Prefix(string prefix) + { + if (prefix == null) + { + throw new ArgumentNullException(nameof(prefix)); + } + + return new NamingPolicy(name => prefix + name); + } + + /// + /// Creates a policy that appends the specified suffix to member names. + /// + /// The suffix to add to the generated column name. + /// A naming policy that adds . + public static NamingPolicy Suffix(string suffix) + { + if (suffix == null) + { + throw new ArgumentNullException(nameof(suffix)); + } + + return new NamingPolicy(name => name + suffix); + } + + /// + /// Creates a policy from a custom member-name transformer. + /// + /// A function that receives a member name and returns a column name. + /// A naming policy that uses . + public static NamingPolicy Custom(Func transformer) + { + return new NamingPolicy(transformer); + } + + /// + /// Composes the current policy with another policy. + /// + /// The next policy to apply. + /// A naming policy that applies this policy and then . + public NamingPolicy Then(NamingPolicy next) + { + if (next == null) + { + throw new ArgumentNullException(nameof(next)); + } + + return new NamingPolicy(name => next.GetColumnName(GetColumnName(name))); + } + + /// + /// Composes the current policy with a custom member-name transformer. + /// + /// The next transformer to apply. + /// A naming policy that applies this policy and then . + public NamingPolicy Then(Func transformer) + { + return Then(Custom(transformer)); + } + + /// + /// Creates a policy that applies this policy and prepends the specified prefix. + /// + /// The prefix to add to the generated column name. + /// A naming policy that adds after applying this policy. + public NamingPolicy WithPrefix(string prefix) + { + return Then(Prefix(prefix)); + } + + /// + /// Creates a policy that applies this policy and appends the specified suffix. + /// + /// The suffix to add to the generated column name. + /// A naming policy that adds after applying this policy. + public NamingPolicy WithSuffix(string suffix) + { + return Then(Suffix(suffix)); + } + + /// + /// Gets the column name for the specified member name. + /// + /// The member name to transform. + /// The generated column name. + public string GetColumnName(string memberName) + { + if (memberName == null) + { + throw new ArgumentNullException(nameof(memberName)); + } + + return _transformer(memberName); + } + + private static string ToSnakeCase(string name) + { + if (string.IsNullOrEmpty(name)) + { + return name; + } + + var builder = new StringBuilder(name.Length + 8); + + for (var i = 0; i < name.Length; i++) + { + var current = name[i]; + if (char.IsUpper(current)) + { + if (ShouldAddUnderscore(name, i)) + { + builder.Append('_'); + } + + builder.Append(char.ToLower(current, CultureInfo.InvariantCulture)); + continue; + } + + builder.Append(current); + } + + return builder.ToString(); + } + + private static bool ShouldAddUnderscore(string name, int index) + { + if (index == 0 || name[index - 1] == '_') + { + return false; + } + + var previous = name[index - 1]; + if (char.IsLower(previous) || char.IsDigit(previous)) + { + return true; + } + + return index + 1 < name.Length && char.IsLower(name[index + 1]); + } + + #region EditorBrowsableStates + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public override string ToString() + { + return base.ToString(); + } + + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public override bool Equals(object obj) + { + return base.Equals(obj); + } + + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public override int GetHashCode() + { + return base.GetHashCode(); + } + + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public new Type GetType() + { + return base.GetType(); + } + #endregion + } +} diff --git a/test/Dapper.FluentMap.Tests/NamingPolicyTests.cs b/test/Dapper.FluentMap.Tests/NamingPolicyTests.cs new file mode 100644 index 0000000..4c2950b --- /dev/null +++ b/test/Dapper.FluentMap.Tests/NamingPolicyTests.cs @@ -0,0 +1,453 @@ +using System; +using Dapper; +using Dapper.FluentMap.Conventions; +using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.Naming; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class NamingPolicyTests + { + [Fact] + public void WithoutNamingPolicyShouldUseDapperDefaultFallback() + { + PreTest(typeof(DefaultPolicyEntity)); + + try + { + using (var connection = OpenConnection()) + { + var entity = connection.QuerySingle( + "SELECT 3 AS Id, 'Ada' AS Name;"); + + Assert.Equal(3, entity.Id); + Assert.Equal("Ada", entity.Name); + } + } + finally + { + PreTest(typeof(DefaultPolicyEntity)); + } + } + + [Fact] + public void SnakeCaseNamingPolicyShouldResolveColumn() + { + PreTest(typeof(SnakeCaseEntity)); + + try + { + FluentMapper.Initialize(c => c.UseNamingPolicy(NamingPolicy.SnakeCase).ForEntity()); + + var member = SqlMapper.GetTypeMap(typeof(SnakeCaseEntity)).GetMember("customer_id"); + + Assert.NotNull(member); + Assert.Equal(typeof(SnakeCaseEntity).GetProperty(nameof(SnakeCaseEntity.CustomerId)), member.Property); + } + finally + { + PreTest(typeof(SnakeCaseEntity)); + } + } + + [Fact] + public void PrefixNamingPolicyShouldResolveColumn() + { + PreTest(typeof(PrefixPolicyEntity)); + + try + { + FluentMapper.Initialize(c => c.UseNamingPolicy(NamingPolicy.SnakeCase.WithPrefix("usr_")).ForEntity()); + + var member = SqlMapper.GetTypeMap(typeof(PrefixPolicyEntity)).GetMember("usr_name"); + + Assert.NotNull(member); + Assert.Equal(typeof(PrefixPolicyEntity).GetProperty(nameof(PrefixPolicyEntity.Name)), member.Property); + } + finally + { + PreTest(typeof(PrefixPolicyEntity)); + } + } + + [Fact] + public void SuffixNamingPolicyShouldResolveColumn() + { + PreTest(typeof(SuffixPolicyEntity)); + + try + { + FluentMapper.Initialize(c => c.UseNamingPolicy(NamingPolicy.SnakeCase.WithSuffix("_txt")).ForEntity()); + + var member = SqlMapper.GetTypeMap(typeof(SuffixPolicyEntity)).GetMember("first_name_txt"); + + Assert.NotNull(member); + Assert.Equal(typeof(SuffixPolicyEntity).GetProperty(nameof(SuffixPolicyEntity.FirstName)), member.Property); + } + finally + { + PreTest(typeof(SuffixPolicyEntity)); + } + } + + [Fact] + public void CustomNamingPolicyShouldResolveColumn() + { + PreTest(typeof(CustomPolicyEntity)); + + try + { + FluentMapper.Initialize(c => c.UseNamingPolicy(name => "x_" + name.ToLowerInvariant()).ForEntity()); + + var member = SqlMapper.GetTypeMap(typeof(CustomPolicyEntity)).GetMember("x_code"); + + Assert.NotNull(member); + Assert.Equal(typeof(CustomPolicyEntity).GetProperty(nameof(CustomPolicyEntity.Code)), member.Property); + } + finally + { + PreTest(typeof(CustomPolicyEntity)); + } + } + + [Fact] + public void ExplicitMappingShouldTakePrecedenceOverNamingPolicy() + { + PreTest(typeof(ExplicitPolicyEntity)); + + try + { + FluentMapper.Initialize(c => + { + c.AddMap(new ExplicitPolicyMap()); + c.UseNamingPolicy(NamingPolicy.SnakeCase).ForEntity(); + }); + + var explicitMember = FluentMapper.Registry.GetFluentPropertyInfo(typeof(ExplicitPolicyEntity), "person_name"); + var policyMember = FluentMapper.Registry.GetFluentPropertyInfo(typeof(ExplicitPolicyEntity), "first_name"); + + Assert.Equal(typeof(ExplicitPolicyEntity).GetProperty(nameof(ExplicitPolicyEntity.FirstName)), explicitMember); + Assert.Null(policyMember); + } + finally + { + PreTest(typeof(ExplicitPolicyEntity)); + } + } + + [Fact] + public void InheritedMappingShouldTakePrecedenceOverNamingPolicy() + { + PreTest(typeof(PolicyBaseUser), typeof(PolicyAdminUser)); + + try + { + FluentMapper.Initialize(c => + { + c.AddMap(new PolicyBaseUserMap()); + c.AddMap(new PolicyAdminUserMap()); + c.UseNamingPolicy(NamingPolicy.Prefix("col")).ForEntity(); + }); + + var inheritedMember = FluentMapper.Registry.GetFluentPropertyInfo(typeof(PolicyAdminUser), "user_id"); + var policyMember = FluentMapper.Registry.GetFluentPropertyInfo(typeof(PolicyAdminUser), "colId"); + + Assert.Equal(typeof(PolicyBaseUser).GetProperty(nameof(PolicyBaseUser.Id)), inheritedMember); + Assert.Null(policyMember); + } + finally + { + PreTest(typeof(PolicyBaseUser), typeof(PolicyAdminUser)); + } + } + + [Fact] + public void NamingPolicyAndConventionShouldResolveTogether() + { + PreTest(typeof(PolicyWithConventionEntity)); + + try + { + FluentMapper.Initialize(c => + { + c.UseNamingPolicy(NamingPolicy.SnakeCase.WithPrefix("usr_")).ForEntity(); + c.AddConvention().ForEntity(); + }); + + var policyMember = SqlMapper.GetTypeMap(typeof(PolicyWithConventionEntity)).GetMember("usr_name"); + var conventionMember = SqlMapper.GetTypeMap(typeof(PolicyWithConventionEntity)).GetMember("key_id"); + + Assert.NotNull(policyMember); + Assert.NotNull(conventionMember); + Assert.Equal(typeof(PolicyWithConventionEntity).GetProperty(nameof(PolicyWithConventionEntity.Name)), policyMember.Property); + Assert.Equal(typeof(PolicyWithConventionEntity).GetProperty(nameof(PolicyWithConventionEntity.Id)), conventionMember.Property); + } + finally + { + PreTest(typeof(PolicyWithConventionEntity)); + } + } + + [Fact] + public void CaseInsensitiveNamingPolicyShouldMatchDifferentCase() + { + PreTest(typeof(CaseInsensitivePolicyEntity)); + + try + { + FluentMapper.Initialize(c => c.UseNamingPolicy(NamingPolicy.Prefix("col"), caseSensitive: false).ForEntity()); + + var member = SqlMapper.GetTypeMap(typeof(CaseInsensitivePolicyEntity)).GetMember("COLName"); + + Assert.NotNull(member); + Assert.Equal(typeof(CaseInsensitivePolicyEntity).GetProperty(nameof(CaseInsensitivePolicyEntity.Name)), member.Property); + } + finally + { + PreTest(typeof(CaseInsensitivePolicyEntity)); + } + } + + [Fact] + public void NamingPolicyShouldApplySameConfigurationToDifferentTypes() + { + PreTest(typeof(FirstSharedPolicyEntity), typeof(SecondSharedPolicyEntity)); + + try + { + FluentMapper.Initialize(c => + c.UseNamingPolicy(NamingPolicy.SnakeCase) + .ForEntity() + .ForEntity()); + + var firstMember = SqlMapper.GetTypeMap(typeof(FirstSharedPolicyEntity)).GetMember("customer_id"); + var secondMember = SqlMapper.GetTypeMap(typeof(SecondSharedPolicyEntity)).GetMember("customer_id"); + + Assert.NotNull(firstMember); + Assert.NotNull(secondMember); + Assert.Equal(typeof(FirstSharedPolicyEntity).GetProperty(nameof(FirstSharedPolicyEntity.CustomerId)), firstMember.Property); + Assert.Equal(typeof(SecondSharedPolicyEntity).GetProperty(nameof(SecondSharedPolicyEntity.CustomerId)), secondMember.Property); + } + finally + { + PreTest(typeof(FirstSharedPolicyEntity), typeof(SecondSharedPolicyEntity)); + } + } + + [Fact] + public void InvalidNamingPolicyShouldThrowConfigurationException() + { + PreTest(typeof(InvalidPolicyEntity)); + + try + { + var exception = Assert.Throws(() => + FluentMapper.Initialize(c => c.UseNamingPolicy(_ => null).ForEntity())); + + Assert.Contains("empty column name", exception.Message); + Assert.Contains(nameof(InvalidPolicyEntity.Name), exception.Message); + } + finally + { + PreTest(typeof(InvalidPolicyEntity)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void NamingPolicyShouldMaterializeWithDapper() + { + PreTest(typeof(IntegrationPolicyEntity)); + + try + { + FluentMapper.Initialize(c => c.UseNamingPolicy(NamingPolicy.SnakeCase).ForEntity()); + + using (var connection = OpenConnection()) + { + var entity = connection.QuerySingle( + "SELECT 42 AS customer_id, 'Grace' AS first_name;"); + + Assert.Equal(42, entity.CustomerId); + Assert.Equal("Grace", entity.FirstName); + } + } + finally + { + PreTest(typeof(IntegrationPolicyEntity)); + } + } + + [Fact] + public void NamingPolicyShouldNotChangeDapperMatchNamesWithUnderscores() + { + PreTest(typeof(SnakeCaseEntity)); + var original = DefaultTypeMap.MatchNamesWithUnderscores; + + try + { + FluentMapper.Initialize(c => c.UseNamingPolicy(NamingPolicy.SnakeCase).ForEntity()); + + Assert.Equal(original, DefaultTypeMap.MatchNamesWithUnderscores); + } + finally + { + DefaultTypeMap.MatchNamesWithUnderscores = original; + PreTest(typeof(SnakeCaseEntity)); + } + } + + [Fact] + public void DapperUnderscoreMatchingShouldMapSnakeCaseOnlyWhenGlobalFlagIsEnabled() + { + PreTest(typeof(NativeUnderscoreEntity)); + var original = DefaultTypeMap.MatchNamesWithUnderscores; + + try + { + DefaultTypeMap.MatchNamesWithUnderscores = false; + var defaultMember = new DefaultTypeMap(typeof(NativeUnderscoreEntity)).GetMember("customer_id"); + + DefaultTypeMap.MatchNamesWithUnderscores = true; + var underscoreMember = new DefaultTypeMap(typeof(NativeUnderscoreEntity)).GetMember("customer_id"); + + Assert.Null(defaultMember); + Assert.NotNull(underscoreMember); + Assert.Equal(typeof(NativeUnderscoreEntity).GetProperty(nameof(NativeUnderscoreEntity.CustomerId)), underscoreMember.Property); + } + finally + { + DefaultTypeMap.MatchNamesWithUnderscores = original; + PreTest(typeof(NativeUnderscoreEntity)); + } + } + + private static SqliteConnection OpenConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + + private static void PreTest(params Type[] types) + { + FluentMapper.Reset(types); + } + + private class DefaultPolicyEntity + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private class SnakeCaseEntity + { + public int CustomerId { get; set; } + } + + private class PrefixPolicyEntity + { + public string Name { get; set; } + } + + private class SuffixPolicyEntity + { + public string FirstName { get; set; } + } + + private class CustomPolicyEntity + { + public string Code { get; set; } + } + + private class ExplicitPolicyEntity + { + public string FirstName { get; set; } + } + + private class ExplicitPolicyMap : EntityMap + { + public ExplicitPolicyMap() + { + Map(e => e.FirstName).ToColumn("person_name"); + } + } + + private class PolicyBaseUser + { + public int Id { get; set; } + } + + private class PolicyAdminUser : PolicyBaseUser + { + } + + private class PolicyBaseUserMap : EntityMap + { + public PolicyBaseUserMap() + { + Map(e => e.Id).ToColumn("user_id"); + } + } + + private class PolicyAdminUserMap : EntityMap + { + public PolicyAdminUserMap() + { + IncludeBase(); + } + } + + private class PolicyWithConventionEntity + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private class KeyConvention : Convention + { + public KeyConvention() + { + Properties() + .Where(p => p.Name == "Id") + .Configure(c => c.HasColumnName("key_id")); + } + } + + private class CaseInsensitivePolicyEntity + { + public string Name { get; set; } + } + + private class FirstSharedPolicyEntity + { + public int CustomerId { get; set; } + } + + private class SecondSharedPolicyEntity + { + public int CustomerId { get; set; } + } + + private class InvalidPolicyEntity + { + public string Name { get; set; } + } + + private class IntegrationPolicyEntity + { + public int CustomerId { get; set; } + + public string FirstName { get; set; } + } + + private class NativeUnderscoreEntity + { + public int CustomerId { get; set; } + } + } +} From f42a5f0e95c4bfd83d22dab129f66e7ddb82597f Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sun, 26 Jul 2026 09:44:49 -0300 Subject: [PATCH 05/20] feat: modernize mapping registration --- README.md | 19 + docs/sdd/etapa-3/01-mapping-registration.md | 290 ++++++++ docs/sdd/etapa-3/README.md | 45 ++ docs/sdd/etapa-3/decisions.md | 14 + docs/sdd/etapa-3/status.md | 7 + .../Configuration/FluentMapConfiguration.cs | 250 ++++++- src/Dapper.FluentMap/MappingRegistry.cs | 11 +- .../Utils/FluentMapConfigurationExtensions.cs | 2 +- .../MappingRegistrationTests.cs | 638 ++++++++++++++++++ 9 files changed, 1272 insertions(+), 4 deletions(-) create mode 100644 docs/sdd/etapa-3/01-mapping-registration.md create mode 100644 docs/sdd/etapa-3/README.md create mode 100644 docs/sdd/etapa-3/decisions.md create mode 100644 docs/sdd/etapa-3/status.md create mode 100644 test/Dapper.FluentMap.Tests/MappingRegistrationTests.cs diff --git a/README.md b/README.md index 5c3adf1..de44566 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,25 @@ FluentMapper.Initialize(config => }); ``` +You can also register map types directly when they have a public parameterless constructor: +```csharp +FluentMapper.Initialize(config => + { + config + .AddMap() + .AddMap(); + }); +``` + +Assembly scanning is available as a convenience, while explicit `AddMap()` registration remains the path that does not require scanning: +```csharp +FluentMapper.Initialize(config => + { + config.AddMapsFromAssemblyContaining(); + config.AddMapsFromAssembly(typeof(ProductMap).Assembly, "App.Domain.Maps"); + }); +``` + #### Convention based mapping When you have a lot of entity types, creating manual mapping classes can become plumbing. If your column names adhere to some kind of naming convention, you might be better off by configuring a mapping convention. diff --git a/docs/sdd/etapa-3/01-mapping-registration.md b/docs/sdd/etapa-3/01-mapping-registration.md new file mode 100644 index 0000000..efb5696 --- /dev/null +++ b/docs/sdd/etapa-3/01-mapping-registration.md @@ -0,0 +1,290 @@ +# 01 - Registro E Descoberta De Mappings + +## Specification + +Modernizar o registro de mappings sem abandonar a proposta central do FluentMap: mapping externo, fortemente tipado e sem atributos no modelo. + +Objetivos tratados: + +- preservar `AddMap(new CustomerMap())`; +- adicionar registro explicito sem scanning por tipo de map, como `AddMap()`; +- adicionar descoberta por assembly como conveniencia; +- adicionar marker type para escolher o assembly sem depender de `Assembly.GetCallingAssembly()`; +- integrar todos os caminhos ao `MappingRegistry`; +- preservar validacoes, inheritance mappings, conventions, naming policies e precedencia consolidada; +- tornar duplicidades deterministicas e diagnosticas; +- documentar reflection restante sem declarar suporte AOT/trimming completo. + +Fora do objetivo: + +- remover APIs antigas; +- criar DI container ou integrar `IServiceCollection`; +- transformar scanning em mecanismo principal de startup; +- implementar source generator, analyzer ou AOT completo; +- alterar funcionalmente Dommel. + +## Discovery + +Arquivos analisados: + +- `AGENTS.md` +- `.agents/skills/run-tests/SKILL.md` +- `docs/sdd/etapa-1/README.md` +- `docs/sdd/etapa-1/status.md` +- `docs/sdd/etapa-1/decisions.md` +- `docs/sdd/etapa-1/04-mapping-registry-cache.md` +- `docs/sdd/etapa-2/README.md` +- `docs/sdd/etapa-2/status.md` +- `docs/sdd/etapa-2/decisions.md` +- `docs/sdd/etapa-2/01-member-path.md` +- `docs/sdd/etapa-2/02-configuration-validation.md` +- `docs/sdd/etapa-2/03-inherited-mappings.md` +- `docs/sdd/etapa-2/04-naming-policies.md` +- `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs` +- `src/Dapper.FluentMap/Utils/FluentMapConfigurationExtensions.cs` +- `src/Dapper.FluentMap/MappingRegistry.cs` +- `src/Dapper.FluentMap/Mapping/EntityMap.cs` +- `src/Dapper.FluentMap/MappingConfigurationValidator.cs` +- `src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs` +- testes de manual mapping, conventions e registry. + +Formas atuais de registro: + +- `FluentMapper.Initialize(Action)` reutiliza uma instancia estatica de `FluentMapConfiguration`. +- `FluentMapConfiguration.AddMap(IEntityMap mapper)` registra uma instancia ja criada. +- `FluentMapConfiguration.AddConvention()` cria a convention por `new()` e retorna `FluentConventionConfiguration`. +- `FluentConventionConfiguration.ForEntity()` aplica convention para uma entidade explicita. +- `FluentConventionConfiguration.ForEntitiesInAssembly(...)` usa `Assembly.GetExportedTypes()` para entidades e registra conventions por tipo. +- `FluentMapConfigurationExtensions.ApplyMapsFromAssemblies(...)` e a API historica de discovery de entity maps por assembly. + +Como `EntityMap` e registrado hoje: + +- o consumidor instancia manualmente o map; +- `AddMap(IEntityMap)` valida nulo e chama `FluentMapper.Registry.AddEntityMap(mapper)`; +- `MappingRegistry.AddEntityMap` valida duplicidade de entidade, valida o map, valida bases incluidas, valida composicao efetiva, grava em `EntityMaps`, invalida cache do tipo e instala `FluentMapTypeMap` no Dapper. + +Mappings por tipo: + +- nao havia API direta `AddMap()`; +- o caminho por tipo existia apenas indiretamente em `ApplyMapsFromAssemblies(...)`, via reflection. + +Instanciacao: + +- registro explicito exigia instancia manual; +- conventions usam constraint `new()`; +- discovery historico usa `Activator.CreateInstance(type)`. + +Assembly scanning historico: + +- `ApplyMapsFromAssemblies(...)` chama `Assembly.GetTypes()`; +- ignora tipos abstratos e interfaces; +- encontra tipos com interface fechada `IEntityMap<>`; +- detecta mais de um map para a mesma entidade antes do registro; +- usa `GetMethod(nameof(AddMap))`, `MakeGenericMethod(...)`, `Invoke(...)` e `Activator.CreateInstance(...)`; +- a ordem de registro vem da ordem retornada por reflection. + +Constraints de construtor: + +- instancia manual nao exige construtor especifico da API; +- discovery historico exige construtor sem parametros em runtime; +- quando construtor falta ou lanca, a falha vem de reflection e pode chegar embrulhada por `TargetInvocationException`. + +Duplicidades: + +- duplicidade de entidade no registry falha com `FluentMapConfigurationException`; +- duplicidade dentro do mesmo assembly scan historico falha com `InvalidOperationException`; +- o modelo validado na Etapa 2 nao permite conflito silencioso de coluna em `PropertyMap` do core. + +Validacao: + +- entity maps sao validados no registro global, antes de gravar no registry; +- inheritance por `IncludeBase()` exige base map ja registrado; +- conventions e naming policies compartilham o pipeline de convention e validacao existente. + +Scanning de tipos invalidos: + +- abstratos e interfaces ja sao ignorados pelo discovery historico; +- genericos abertos nao sao tratados explicitamente no discovery historico; +- tipos concretos sem construtor publico sem parametros falham durante `Activator.CreateInstance`. + +## Decision + +APIs adicionadas: + +```csharp +configuration.AddMap(); + +configuration + .AddMap() + .AddMap(); + +configuration.AddMapsFromAssembly(typeof(CustomerMap).Assembly); + +configuration.AddMapsFromAssemblyContaining(); +``` + +Tambem serao aceitos filtros opcionais de namespace nos metodos de scanning, seguindo o estilo de `ForEntitiesInAssembly(...)`: + +```csharp +configuration.AddMapsFromAssembly(assembly, "App.Domain.Maps"); +configuration.AddMapsFromAssemblyContaining("App.Domain.Maps"); +``` + +Registro de instancia: + +- `AddMap(IEntityMap mapper)` permanece como API historica; +- assinatura e comportamento sao preservados. + +Registro generico: + +- `AddMap()` representa o caminho explicito sem assembly scanning; +- `TMap` deve implementar `IEntityMap` e possuir construtor publico sem parametros; +- o tipo deve implementar exatamente uma interface fechada `IEntityMap`; +- a entidade e inferida dessa interface e o registro passa pelo mesmo `MappingRegistry`. + +Assembly scanning: + +- scanning moderno usa apenas tipos exportados da assembly; +- tipos abstratos, interfaces e genericos abertos sao ignorados; +- candidatos sao ordenados deterministamente por nome completo antes de qualquer decisao; +- duplicidade de entidade dentro do conjunto descoberto falha antes da instanciacao; +- maps sao instanciados antes do registro para permitir ordenacao por `IncludeBase()`; +- quando um map inclui base map tambem descoberto no mesmo conjunto, o registro e ordenado para registrar a base primeiro; +- ciclos ou dependencias impossiveis falham com diagnostico. + +Duplicidades: + +- mesmo mapping registrado duas vezes: falha pelo registry porque a entidade ja possui map; +- mesma entidade com mappings diferentes: falha pelo registry ou pelo preflight do scanning; +- registro explicito seguido de scanning da mesma entidade: falha pelo registry durante o scanning; +- nao ha "ultimo ganha". + +Reflection: + +- `AddMap()` nao faz assembly scanning, mas usa reflection limitada para inferir `TEntity` de `IEntityMap`; +- `AddMapsFromAssembly(...)` depende de reflection e `Activator.CreateInstance`; +- `MappingRegistry` ainda usa `Activator.CreateInstance(typeof(FluentMapTypeMap<>).MakeGenericType(type))` para instalar type map no Dapper; +- AOT/trimming completo permanece divida futura. + +## Delivery + +Arquivos alterados: + +- `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs` +- `src/Dapper.FluentMap/MappingRegistry.cs` +- `src/Dapper.FluentMap/Utils/FluentMapConfigurationExtensions.cs` +- `test/Dapper.FluentMap.Tests/MappingRegistrationTests.cs` +- `README.md` +- `docs/sdd/etapa-3/README.md` +- `docs/sdd/etapa-3/status.md` +- `docs/sdd/etapa-3/decisions.md` +- `docs/sdd/etapa-3/01-mapping-registration.md` + +API anterior preservada: + +```csharp +configuration.AddMap(new CustomerMap()); +``` + +APIs novas: + +```csharp +configuration.AddMap(); + +configuration + .AddMap() + .AddMap(); + +configuration.AddMapsFromAssembly(typeof(CustomerMap).Assembly); +configuration.AddMapsFromAssembly(typeof(CustomerMap).Assembly, "App.Domain.Maps"); + +configuration.AddMapsFromAssemblyContaining(); +configuration.AddMapsFromAssemblyContaining("App.Domain.Maps"); +``` + +Implementacao: + +- `FluentMapConfiguration.AddMap()` cria o map e retorna a propria configuracao para permitir chaining; +- o tipo de entidade e inferido pela interface fechada `IEntityMap`; +- `MappingRegistry` recebeu overload interno `AddEntityMap(Type, IEntityMap)` para registrar o tipo inferido sem `MakeGenericMethod`/`Invoke`; +- o overload generico antigo do registry foi preservado e delega para o novo overload interno; +- `AddMapsFromAssembly(...)` usa tipos exportados da assembly, filtra namespace opcional, ignora abstratos/interfaces/genericos abertos, detecta duplicidades antes de instanciar e registra de forma deterministica; +- `AddMapsFromAssemblyContaining(...)` usa a assembly do marker type e compartilha o mesmo fluxo; +- scanning instancia todos os maps descobertos antes do registro e ordena por `IncludeBase()` quando base e derivado aparecem no mesmo conjunto descoberto; +- `ApplyMapsFromAssemblies(...)` foi mantido por compatibilidade e recebeu apenas ajuste de comentario XML para evitar ambiguidade com a nova overload. + +Duplicidades: + +- mesmo mapping registrado duas vezes: `FluentMapConfigurationException` pelo registry; +- mesma entidade com mappings diferentes por registro explicito: `FluentMapConfigurationException` pelo registry; +- mesma entidade duplicada dentro do scanning: `FluentMapConfigurationException` antes de qualquer registro; +- registro explicito seguido de scanning da mesma entidade: `FluentMapConfigurationException` durante o registro descoberto; +- nenhum fluxo novo usa "ultimo ganha". + +Reflection restante: + +- `AddMap()` nao faz assembly scanning, mas usa reflection para identificar a unica interface `IEntityMap`; +- `AddMapsFromAssembly(...)` usa `Assembly.GetExportedTypes()` e `Activator.CreateInstance`; +- `MappingRegistry` continua usando `Activator.CreateInstance(typeof(FluentMapTypeMap<>).MakeGenericType(type))` para instalar type maps no Dapper; +- essas dependencias ficam registradas como divida futura para AOT/trimming. + +Compatibilidade: + +- nenhuma API publica foi removida ou marcada como obsoleta; +- `AddMap(new CustomerMap())` continua funcionando; +- target `netstandard2.0` do core foi preservado; +- Dommel nao recebeu alteracao funcional; +- conventions, naming policies, inheritance mappings e validacao continuam passando pelo `MappingRegistry`. + +Testes adicionados em `MappingRegistrationTests` cobrem: + +- registro por instancia existente; +- registro generico; +- materializacao real com Dapper via registro generico; +- chaining de multiplos mappings explicitos; +- scanning por assembly; +- marker type; +- map abstrato ignorado; +- tipo invalido que nao implementa `IEntityMap`; +- mesmo mapping registrado duas vezes; +- entidade duplicada com maps diferentes; +- duplicidade detectada dentro do scanning antes de registro parcial; +- scanning apos registro explicito; +- erro de construtor; +- validacao integrada; +- ordenacao de scanning para inheritance mappings. + +## Validation + +Ambiente: + +- SDK: `10.0.302` +- test runner detectado: VSTest com xUnit v3 +- projeto principal: `netstandard2.0` +- projeto de testes do core: `net10.0` + +Comandos de validacao localizada: + +- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~MappingRegistrationTests"` + - resultado: sucesso, 15 testes aprovados. +- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~MappingRegistrationTests|FullyQualifiedName~MappingRegistryTests|FullyQualifiedName~MappingCompositionTests|FullyQualifiedName~InheritedMappingTests|FullyQualifiedName~ConventionTests|FullyQualifiedName~NamingPolicyTests|FullyQualifiedName~DapperIntegrationTests"` + - resultado: sucesso, 69 testes aprovados. + +Validacao final: + +- `dotnet restore` + - resultado: sucesso. +- `dotnet build` + - resultado: sucesso, 0 warnings, 0 erros. +- `dotnet test` + - resultado: sucesso, 106 testes aprovados no core e 7 testes aprovados no Dommel. +- `dotnet build --configuration Release` + - resultado: sucesso, 0 warnings, 0 erros. +- `dotnet test --configuration Release` + - resultado: sucesso, 106 testes aprovados no core e 7 testes aprovados no Dommel. +- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release` + - resultado: sucesso, 106 testes aprovados. +- `dotnet build .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release --no-restore` + - resultado: sucesso, 0 warnings, 0 erros. + +`dotnet pack` nao foi executado porque nao houve mudanca de empacotamento, metadados NuGet ou targets. diff --git a/docs/sdd/etapa-3/README.md b/docs/sdd/etapa-3/README.md new file mode 100644 index 0000000..7efaa95 --- /dev/null +++ b/docs/sdd/etapa-3/README.md @@ -0,0 +1,45 @@ +# Etapa 3 + +## Objetivo + +Modernizar pontos de configuracao avancada do `Dapper.FluentMap`, preservando a API publica historica e preparando evolucoes seguras em registro de mappings, constructor mapping, tipos imutaveis, validacao e diagnosticos. + +## Dependencia Das Etapas 1 E 2 + +A Etapa 3 depende das decisoes das Etapas 1 e 2 sobre `MappingRegistry`, cache estruturado, precedencia entre mappings explicitos, mappings herdados, conventions, naming policies e fallback do Dapper. + +Antes de alterar uma decisao registrada nas etapas anteriores, deve existir evidencia tecnica e a nova decisao deve ser documentada nesta pasta. + +## Leitura Obrigatoria + +Antes das proximas entregas desta etapa, leia: + +- `docs/sdd/etapa-1/README.md` +- `docs/sdd/etapa-1/decisions.md` +- `docs/sdd/etapa-1/04-mapping-registry-cache.md` +- `docs/sdd/etapa-2/README.md` +- `docs/sdd/etapa-2/status.md` +- `docs/sdd/etapa-2/decisions.md` +- `docs/sdd/etapa-2/04-naming-policies.md` +- `docs/sdd/etapa-3/README.md` +- `docs/sdd/etapa-3/status.md` +- `docs/sdd/etapa-3/decisions.md` +- o relatorio da entrega anterior nesta pasta + +## Compatibilidade Publica + +A API publica existente deve ser preservada sempre que razoavelmente possivel. APIs novas devem ser aditivas e nao devem marcar membros historicos como obsoletos sem estrategia explicita de migracao. + +## TargetFrameworks + +Os projetos de `src/` devem continuar compativeis com `netstandard2.0`. Projetos de teste devem permanecer no framework ja consolidado pelas migracoes anteriores. + +## Escopo + +Entregas: + +1. 01 - Registro e descoberta de mappings +2. 02 - Constructor mapping, records e tipos imutaveis +3. 03 - Validate e Explain + +O escopo padrao continua sendo o projeto principal `Dapper.FluentMap`. `Dapper.FluentMap.Dommel` nao deve receber alteracao funcional nesta etapa, salvo adaptacao tecnica estritamente necessaria provocada por API compartilhada. diff --git a/docs/sdd/etapa-3/decisions.md b/docs/sdd/etapa-3/decisions.md new file mode 100644 index 0000000..9385f7a --- /dev/null +++ b/docs/sdd/etapa-3/decisions.md @@ -0,0 +1,14 @@ +# Decisoes Da Etapa 3 + +Registre aqui apenas decisoes que afetem entregas posteriores. + +## Registro E Descoberta De Mappings + +- `AddMap(IEntityMap)` permanece como API historica de registro por instancia. +- `AddMap()` e o caminho explicito moderno sem assembly scanning; `TMap` deve implementar exatamente uma interface fechada `IEntityMap` e possuir construtor publico sem parametros. +- `AddMap()` infere `TEntity` por reflection limitada sobre as interfaces do map e registra pelo mesmo `MappingRegistry`. +- `AddMapsFromAssembly(...)` e `AddMapsFromAssemblyContaining(...)` sao conveniencias de discovery e nao substituem o caminho explicito. +- O scanning moderno considera apenas tipos exportados, concretos e fechados que implementam `IEntityMap`. +- O scanning aceita filtros opcionais de namespace, ordena candidatos de forma deterministica e registra maps base incluidos antes dos derivados quando ambos sao descobertos juntos. +- Duplicidade de entidade, seja por registro explicito, scanning ou combinacao dos dois, e erro de configuracao; nao ha comportamento "ultimo ganha". +- Reflection restante desta entrega: inferencia de entidade via `IEntityMap`, scanning por assembly, `Activator.CreateInstance` para criar maps descobertos e criacao interna de `FluentMapTypeMap<>` no `MappingRegistry`. AOT/trimming completo permanece fora do contrato atual. diff --git a/docs/sdd/etapa-3/status.md b/docs/sdd/etapa-3/status.md new file mode 100644 index 0000000..eb7b7ab --- /dev/null +++ b/docs/sdd/etapa-3/status.md @@ -0,0 +1,7 @@ +# Status Da Etapa 3 + +| Entrega | Status | Commit | +|---|---|---| +| 01 - Registro e descoberta de mappings | Concluido | feat: modernize mapping registration | +| 02 - Constructor mapping e imutaveis | Pendente | - | +| 03 - Validate e Explain | Pendente | - | diff --git a/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs b/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs index a36a393..e1e74d6 100644 --- a/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs +++ b/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs @@ -1,6 +1,8 @@ -using System; -using System.Linq; +using System; +using System.Collections.Generic; using System.ComponentModel; +using System.Linq; +using System.Reflection; using Dapper.FluentMap.Conventions; using Dapper.FluentMap.Mapping; using Dapper.FluentMap.Naming; @@ -30,6 +32,65 @@ public void AddMap(IEntityMap mapper) where TEntity : class FluentMapper.Registry.AddEntityMap(mapper); } + /// + /// Adds a new instance of the specified entity map type to the configuration of Dapper.FluentMap. + /// + /// The type of the entity map to create and register. + /// The current instance of . + public FluentMapConfiguration AddMap() + where TMap : IEntityMap, new() + { + var mapType = typeof(TMap); + var entityType = GetMappedEntityType(mapType); + var mapper = CreateEntityMap(mapType); + + FluentMapper.Registry.AddEntityMap(entityType, mapper); + return this; + } + + /// + /// Finds exported entity map types in the specified assembly and adds them to the configuration of Dapper.FluentMap. + /// + /// The assembly to scan for entity maps. + /// Optional namespaces used to filter discovered entity map types. + /// The current instance of . + public FluentMapConfiguration AddMapsFromAssembly(Assembly assembly, params string[] namespaces) + { + if (assembly == null) + { + throw new ArgumentNullException(nameof(assembly)); + } + + var definitions = FindEntityMapDefinitions(assembly, namespaces).ToList(); + EnsureNoDuplicateEntityMaps(definitions); + + var registrations = definitions + .Select(definition => new EntityMapRegistration( + definition.MapType, + definition.EntityType, + CreateEntityMap(definition.MapType))) + .ToList(); + + foreach (var registration in OrderByIncludedBaseMaps(registrations)) + { + FluentMapper.Registry.AddEntityMap(registration.EntityType, registration.Map); + } + + return this; + } + + /// + /// Finds exported entity map types in the assembly containing + /// and adds them to the configuration of Dapper.FluentMap. + /// + /// A marker type from the assembly to scan. + /// Optional namespaces used to filter discovered entity map types. + /// The current instance of . + public FluentMapConfiguration AddMapsFromAssemblyContaining(params string[] namespaces) + { + return AddMapsFromAssembly(typeof(TMarker).GetTypeInfo().Assembly, namespaces); + } + /// /// Adds the specified to the configuration of Dapper.FluentMap. /// @@ -81,6 +142,191 @@ public FluentConventionConfiguration UseNamingPolicy(Func transf return UseNamingPolicy(NamingPolicy.Custom(transformer), caseSensitive); } + private static IEnumerable FindEntityMapDefinitions(Assembly assembly, string[] namespaces) + { + return GetExportedTypes(assembly) + .Where(IsConcreteEntityMapType) + .Where(type => IsNamespaceMatch(type, namespaces)) + .OrderBy(type => type.FullName, StringComparer.Ordinal) + .ThenBy(type => type.AssemblyQualifiedName, StringComparer.Ordinal) + .Select(type => new EntityMapDefinition(type, GetMappedEntityType(type))); + } + + private static IEnumerable GetExportedTypes(Assembly assembly) + { + try + { + return assembly.GetExportedTypes(); + } + catch (ReflectionTypeLoadException ex) + { + throw new FluentMapConfigurationException( + $"Cannot load exported types from assembly '{assembly.FullName}'.", + ex); + } + } + + private static bool IsConcreteEntityMapType(Type type) + { + var typeInfo = type.GetTypeInfo(); + return !typeInfo.IsAbstract && + !typeInfo.IsInterface && + !typeInfo.ContainsGenericParameters && + typeof(IEntityMap).GetTypeInfo().IsAssignableFrom(typeInfo); + } + + private static bool IsNamespaceMatch(Type type, string[] namespaces) + { + return namespaces == null || + namespaces.Length == 0 || + namespaces.Any(ns => string.Equals(ns, type.Namespace, StringComparison.Ordinal)); + } + + private static Type GetMappedEntityType(Type mapType) + { + var entityMapInterfaces = mapType.GetInterfaces() + .Where(type => type.GetTypeInfo().IsGenericType && + type.GetGenericTypeDefinition() == typeof(IEntityMap<>)) + .ToList(); + + if (entityMapInterfaces.Count != 1) + { + throw new FluentMapConfigurationException( + $"Entity map type '{mapType.FullName}' must implement exactly one closed IEntityMap interface."); + } + + var entityType = entityMapInterfaces[0].GetGenericArguments()[0]; + if (!entityType.GetTypeInfo().IsClass) + { + throw new FluentMapConfigurationException( + $"Entity map type '{mapType.FullName}' targets '{entityType.FullName}', but entity maps must target class types."); + } + + return entityType; + } + + private static IEntityMap CreateEntityMap(Type mapType) + { + try + { + return (IEntityMap)Activator.CreateInstance(mapType); + } + catch (Exception ex) + { + throw new FluentMapConfigurationException( + $"Entity map type '{mapType.FullName}' could not be created. Ensure it has a public parameterless constructor and the constructor completes successfully.", + ex); + } + } + + private static void EnsureNoDuplicateEntityMaps(IList definitions) + { + var duplicates = definitions + .GroupBy(definition => definition.EntityType) + .Where(group => group.Count() > 1) + .ToList(); + + if (duplicates.Count == 0) + { + return; + } + + var duplicateDescriptions = duplicates + .Select(group => + $"entity '{group.Key.FullName}' mapped by {string.Join(", ", group.Select(definition => "'" + definition.MapType.FullName + "'"))}"); + + throw new FluentMapConfigurationException( + "Multiple entity maps were discovered for the same entity: " + + string.Join("; ", duplicateDescriptions) + "."); + } + + private static IList OrderByIncludedBaseMaps(IList registrations) + { + var ordered = new List(); + var remaining = registrations.ToList(); + + while (remaining.Count > 0) + { + var progressed = false; + + foreach (var registration in remaining.ToList()) + { + if (!HasPendingIncludedBaseMap(registration, remaining, ordered)) + { + remaining.Remove(registration); + ordered.Add(registration); + progressed = true; + } + } + + if (!progressed) + { + throw new FluentMapConfigurationException( + "Entity maps discovered from assembly could not be ordered by included base mappings. Check for cyclic or invalid IncludeBase configuration."); + } + } + + return ordered; + } + + private static bool HasPendingIncludedBaseMap( + EntityMapRegistration registration, + IList remaining, + IList ordered) + { + foreach (var includedBaseType in GetIncludedBaseTypes(registration.Map)) + { + if (ordered.Any(map => map.EntityType == includedBaseType)) + { + continue; + } + + if (remaining.Any(map => map.EntityType == includedBaseType)) + { + return true; + } + } + + return false; + } + + private static IList GetIncludedBaseTypes(IEntityMap map) + { + var mapWithIncludedBases = map as IEntityMapWithIncludedBaseTypes; + return mapWithIncludedBases == null + ? new Type[0] + : mapWithIncludedBases.IncludedBaseTypes; + } + + private sealed class EntityMapDefinition + { + internal EntityMapDefinition(Type mapType, Type entityType) + { + MapType = mapType; + EntityType = entityType; + } + + internal Type MapType { get; } + + internal Type EntityType { get; } + } + + private sealed class EntityMapRegistration + { + internal EntityMapRegistration(Type mapType, Type entityType, IEntityMap map) + { + MapType = mapType; + EntityType = entityType; + Map = map; + } + + internal Type MapType { get; } + + internal Type EntityType { get; } + + internal IEntityMap Map { get; } + } + #region EditorBrowsableStates /// [EditorBrowsable(EditorBrowsableState.Never)] diff --git a/src/Dapper.FluentMap/MappingRegistry.cs b/src/Dapper.FluentMap/MappingRegistry.cs index 85786e0..a8490c9 100644 --- a/src/Dapper.FluentMap/MappingRegistry.cs +++ b/src/Dapper.FluentMap/MappingRegistry.cs @@ -25,7 +25,16 @@ internal sealed class MappingRegistry internal void AddEntityMap(IEntityMap mapper) where TEntity : class { - var type = typeof(TEntity); + AddEntityMap(typeof(TEntity), mapper); + } + + internal void AddEntityMap(Type type, IEntityMap mapper) + { + if (type == null) + { + throw new ArgumentNullException(nameof(type)); + } + if (mapper == null) { throw new ArgumentNullException(nameof(mapper)); diff --git a/src/Dapper.FluentMap/Utils/FluentMapConfigurationExtensions.cs b/src/Dapper.FluentMap/Utils/FluentMapConfigurationExtensions.cs index 3009a9e..4349cdf 100644 --- a/src/Dapper.FluentMap/Utils/FluentMapConfigurationExtensions.cs +++ b/src/Dapper.FluentMap/Utils/FluentMapConfigurationExtensions.cs @@ -15,7 +15,7 @@ public static class FluentMapConfigurationExtensions /// /// Finds all types, from provided assemblies, implementing /// and applies them to , - /// by calling and passing an instance of found type. + /// by calling AddMap and passing an instance of found type. /// /// The instance. /// The assemblies to scan for entity maps. diff --git a/test/Dapper.FluentMap.Tests/MappingRegistrationTests.cs b/test/Dapper.FluentMap.Tests/MappingRegistrationTests.cs new file mode 100644 index 0000000..1267ce1 --- /dev/null +++ b/test/Dapper.FluentMap.Tests/MappingRegistrationTests.cs @@ -0,0 +1,638 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Dapper; +using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.TypeMaps; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class MappingRegistrationTests + { + [Fact] + public void InstanceRegistrationShouldContinueToAddEntityMap() + { + ResetMapper(typeof(InstanceRegistrationEntity)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new InstanceRegistrationMap())); + + var entityMap = FluentMapper.EntityMaps.Single(); + Assert.Equal(typeof(InstanceRegistrationEntity), entityMap.Key); + Assert.IsType(entityMap.Value); + } + finally + { + ResetMapper(typeof(InstanceRegistrationEntity)); + } + } + + [Fact] + public void GenericRegistrationShouldAddEntityMapAndDapperTypeMap() + { + ResetMapper(typeof(GenericRegistrationEntity)); + + try + { + FluentMapper.Initialize(c => c.AddMap()); + + Assert.IsType(FluentMapper.EntityMaps[typeof(GenericRegistrationEntity)]); + Assert.IsType>(SqlMapper.GetTypeMap(typeof(GenericRegistrationEntity))); + + var property = FluentMapper.Registry.GetFluentPropertyInfo(typeof(GenericRegistrationEntity), "generic_id"); + Assert.Equal(typeof(GenericRegistrationEntity).GetProperty(nameof(GenericRegistrationEntity.Id)), property); + } + finally + { + ResetMapper(typeof(GenericRegistrationEntity)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void GenericRegistrationShouldMaterializeConfiguredColumnWithDapper() + { + ResetMapper(typeof(GenericIntegrationEntity)); + + try + { + FluentMapper.Initialize(c => c.AddMap()); + + using (var connection = OpenConnection()) + { + var entity = connection.QuerySingle( + "SELECT 31 AS integration_id, 'modern' AS Name;"); + + Assert.Equal(31, entity.Id); + Assert.Equal("modern", entity.Name); + } + } + finally + { + ResetMapper(typeof(GenericIntegrationEntity)); + } + } + + [Fact] + public void GenericRegistrationShouldChainMultipleExplicitMappings() + { + ResetMapper(typeof(FirstExplicitEntity), typeof(SecondExplicitEntity)); + + try + { + FluentMapper.Initialize(c => c + .AddMap() + .AddMap()); + + Assert.Equal(2, FluentMapper.EntityMaps.Count); + Assert.IsType(FluentMapper.EntityMaps[typeof(FirstExplicitEntity)]); + Assert.IsType(FluentMapper.EntityMaps[typeof(SecondExplicitEntity)]); + } + finally + { + ResetMapper(typeof(FirstExplicitEntity), typeof(SecondExplicitEntity)); + } + } + + [Fact] + public void AddMapsFromAssemblyShouldRegisterDiscoveredMaps() + { + ResetMapper( + typeof(MappingRegistrationScan.Basic.Customer), + typeof(MappingRegistrationScan.Basic.Order)); + + try + { + FluentMapper.Initialize(c => c.AddMapsFromAssembly( + typeof(MappingRegistrationScan.Basic.Marker).GetTypeInfo().Assembly, + typeof(MappingRegistrationScan.Basic.Marker).Namespace)); + + Assert.Equal(2, FluentMapper.EntityMaps.Count); + Assert.IsType( + FluentMapper.EntityMaps[typeof(MappingRegistrationScan.Basic.Customer)]); + Assert.IsType( + FluentMapper.EntityMaps[typeof(MappingRegistrationScan.Basic.Order)]); + } + finally + { + ResetMapper( + typeof(MappingRegistrationScan.Basic.Customer), + typeof(MappingRegistrationScan.Basic.Order)); + } + } + + [Fact] + public void AddMapsFromAssemblyContainingShouldUseMarkerAssembly() + { + ResetMapper(typeof(MappingRegistrationScan.MarkerType.MarkerEntity)); + + try + { + FluentMapper.Initialize(c => c.AddMapsFromAssemblyContaining( + typeof(MappingRegistrationScan.MarkerType.Marker).Namespace)); + + var property = FluentMapper.Registry.GetFluentPropertyInfo( + typeof(MappingRegistrationScan.MarkerType.MarkerEntity), + "marker_id"); + + Assert.Equal(typeof(MappingRegistrationScan.MarkerType.MarkerEntity).GetProperty(nameof(MappingRegistrationScan.MarkerType.MarkerEntity.Id)), property); + } + finally + { + ResetMapper(typeof(MappingRegistrationScan.MarkerType.MarkerEntity)); + } + } + + [Fact] + public void AddMapsFromAssemblyShouldIgnoreAbstractMaps() + { + ResetMapper(typeof(MappingRegistrationScan.AbstractOnly.AbstractEntity)); + + try + { + FluentMapper.Initialize(c => c.AddMapsFromAssemblyContaining( + typeof(MappingRegistrationScan.AbstractOnly.Marker).Namespace)); + + Assert.Empty(FluentMapper.EntityMaps); + } + finally + { + ResetMapper(typeof(MappingRegistrationScan.AbstractOnly.AbstractEntity)); + } + } + + [Fact] + public void GenericRegistrationShouldRejectInvalidMapType() + { + ResetMapper(); + + var exception = Assert.Throws( + () => FluentMapper.Initialize(c => c.AddMap())); + + Assert.Contains("exactly one closed IEntityMap", exception.Message); + } + + [Fact] + public void RegisteringSameMapTwiceShouldThrow() + { + ResetMapper(typeof(DuplicateRegistrationEntity)); + + try + { + var exception = Assert.Throws(() => + FluentMapper.Initialize(c => c + .AddMap() + .AddMap())); + + Assert.Contains("already has a configured entity map", exception.Message); + } + finally + { + ResetMapper(typeof(DuplicateRegistrationEntity)); + } + } + + [Fact] + public void RegisteringDifferentMapsForSameEntityShouldThrow() + { + ResetMapper(typeof(DuplicateEntity)); + + try + { + var exception = Assert.Throws(() => + FluentMapper.Initialize(c => c + .AddMap() + .AddMap())); + + Assert.Contains("already has a configured entity map", exception.Message); + } + finally + { + ResetMapper(typeof(DuplicateEntity)); + } + } + + [Fact] + public void ScanningDuplicateEntityMapsShouldThrowBeforeRegistration() + { + ResetMapper(typeof(MappingRegistrationScan.DuplicateScan.DuplicateScanEntity)); + + try + { + var exception = Assert.Throws(() => + FluentMapper.Initialize(c => c.AddMapsFromAssemblyContaining( + typeof(MappingRegistrationScan.DuplicateScan.Marker).Namespace))); + + Assert.Contains("Multiple entity maps were discovered", exception.Message); + Assert.Empty(FluentMapper.EntityMaps); + } + finally + { + ResetMapper(typeof(MappingRegistrationScan.DuplicateScan.DuplicateScanEntity)); + } + } + + [Fact] + public void ScanningAfterExplicitRegistrationShouldThrowDuplicate() + { + ResetMapper(typeof(MappingRegistrationScan.ExplicitThenScan.ExplicitScanEntity)); + + try + { + var exception = Assert.Throws(() => + FluentMapper.Initialize(c => + { + c.AddMap(); + c.AddMapsFromAssemblyContaining( + typeof(MappingRegistrationScan.ExplicitThenScan.Marker).Namespace); + })); + + Assert.Contains("already has a configured entity map", exception.Message); + } + finally + { + ResetMapper(typeof(MappingRegistrationScan.ExplicitThenScan.ExplicitScanEntity)); + } + } + + [Fact] + public void GenericRegistrationShouldWrapConstructorErrors() + { + ResetMapper(typeof(ThrowingConstructorEntity)); + + try + { + var exception = Assert.Throws( + () => FluentMapper.Initialize(c => c.AddMap())); + + Assert.Contains("could not be created", exception.Message); + Assert.NotNull(exception.InnerException); + } + finally + { + ResetMapper(typeof(ThrowingConstructorEntity)); + } + } + + [Fact] + public void GenericRegistrationShouldUseExistingValidation() + { + ResetMapper(typeof(ValidationRegistrationEntity)); + + try + { + var exception = Assert.Throws( + () => FluentMapper.Initialize(c => c.AddMap())); + + Assert.Contains("configured for more than one property path", exception.Message); + } + finally + { + ResetMapper(typeof(ValidationRegistrationEntity)); + } + } + + [Fact] + public void AssemblyScanningShouldRegisterIncludedBaseMapsBeforeDerivedMaps() + { + ResetMapper( + typeof(MappingRegistrationScan.InheritedScan.BaseScanEntity), + typeof(MappingRegistrationScan.InheritedScan.DerivedScanEntity)); + + try + { + FluentMapper.Initialize(c => c.AddMapsFromAssemblyContaining( + typeof(MappingRegistrationScan.InheritedScan.Marker).Namespace)); + + var typeMap = SqlMapper.GetTypeMap(typeof(MappingRegistrationScan.InheritedScan.DerivedScanEntity)); + var inheritedMember = typeMap.GetMember("base_id"); + var derivedMember = typeMap.GetMember("derived_name"); + + Assert.Equal(typeof(MappingRegistrationScan.InheritedScan.BaseScanEntity).GetProperty(nameof(MappingRegistrationScan.InheritedScan.BaseScanEntity.Id)), inheritedMember.Property); + Assert.Equal(typeof(MappingRegistrationScan.InheritedScan.DerivedScanEntity).GetProperty(nameof(MappingRegistrationScan.InheritedScan.DerivedScanEntity.Name)), derivedMember.Property); + } + finally + { + ResetMapper( + typeof(MappingRegistrationScan.InheritedScan.BaseScanEntity), + typeof(MappingRegistrationScan.InheritedScan.DerivedScanEntity)); + } + } + + private static SqliteConnection OpenConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + + private static void ResetMapper(params Type[] types) + { + FluentMapper.Reset(types); + } + + private class InstanceRegistrationEntity + { + public int Id { get; set; } + } + + private class InstanceRegistrationMap : EntityMap + { + public InstanceRegistrationMap() + { + Map(e => e.Id).ToColumn("instance_id"); + } + } + + private class GenericRegistrationEntity + { + public int Id { get; set; } + } + + private class GenericRegistrationMap : EntityMap + { + public GenericRegistrationMap() + { + Map(e => e.Id).ToColumn("generic_id"); + } + } + + private class GenericIntegrationEntity + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private class GenericIntegrationMap : EntityMap + { + public GenericIntegrationMap() + { + Map(e => e.Id).ToColumn("integration_id"); + } + } + + private class FirstExplicitEntity + { + public int Id { get; set; } + } + + private class FirstExplicitMap : EntityMap + { + public FirstExplicitMap() + { + Map(e => e.Id).ToColumn("first_id"); + } + } + + private class SecondExplicitEntity + { + public string Name { get; set; } + } + + private class SecondExplicitMap : EntityMap + { + public SecondExplicitMap() + { + Map(e => e.Name).ToColumn("second_name"); + } + } + + private class NonGenericEntityMap : IEntityMap + { + public IList PropertyMaps { get; } = new List(); + } + + private class DuplicateRegistrationEntity + { + public int Id { get; set; } + } + + private class DuplicateRegistrationMap : EntityMap + { + public DuplicateRegistrationMap() + { + Map(e => e.Id).ToColumn("duplicate_id"); + } + } + + private class DuplicateEntity + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private class FirstDuplicateEntityMap : EntityMap + { + public FirstDuplicateEntityMap() + { + Map(e => e.Id).ToColumn("duplicate_id"); + } + } + + private class SecondDuplicateEntityMap : EntityMap + { + public SecondDuplicateEntityMap() + { + Map(e => e.Name).ToColumn("duplicate_name"); + } + } + + private class ExplicitScanMap : EntityMap + { + public ExplicitScanMap() + { + Map(e => e.Id).ToColumn("explicit_id"); + } + } + + private class ThrowingConstructorEntity + { + public int Id { get; set; } + } + + private class ThrowingConstructorMap : EntityMap + { + public ThrowingConstructorMap() + { + throw new InvalidOperationException("Constructor failed."); + } + } + + private class ValidationRegistrationEntity + { + public int Id { get; set; } + + public int OtherId { get; set; } + } + + private class ValidationRegistrationMap : EntityMap + { + public ValidationRegistrationMap() + { + Map(e => e.Id).ToColumn("same_column"); + Map(e => e.OtherId).ToColumn("same_column"); + } + } + } +} + +namespace Dapper.FluentMap.Tests.MappingRegistrationScan.Basic +{ + public class Marker + { + } + + public class Customer + { + public int Id { get; set; } + } + + public class Order + { + public string Number { get; set; } + } + + public class CustomerMap : EntityMap + { + public CustomerMap() + { + Map(e => e.Id).ToColumn("customer_id"); + } + } + + public class OrderMap : EntityMap + { + public OrderMap() + { + Map(e => e.Number).ToColumn("order_number"); + } + } +} + +namespace Dapper.FluentMap.Tests.MappingRegistrationScan.MarkerType +{ + public class Marker + { + } + + public class MarkerEntity + { + public int Id { get; set; } + } + + public class MarkerEntityMap : EntityMap + { + public MarkerEntityMap() + { + Map(e => e.Id).ToColumn("marker_id"); + } + } +} + +namespace Dapper.FluentMap.Tests.MappingRegistrationScan.AbstractOnly +{ + public class Marker + { + } + + public class AbstractEntity + { + public int Id { get; set; } + } + + public abstract class AbstractEntityMap : EntityMap + { + } +} + +namespace Dapper.FluentMap.Tests.MappingRegistrationScan.DuplicateScan +{ + public class Marker + { + } + + public class DuplicateScanEntity + { + public int Id { get; set; } + + public string Name { get; set; } + } + + public class FirstDuplicateScanMap : EntityMap + { + public FirstDuplicateScanMap() + { + Map(e => e.Id).ToColumn("duplicate_id"); + } + } + + public class SecondDuplicateScanMap : EntityMap + { + public SecondDuplicateScanMap() + { + Map(e => e.Name).ToColumn("duplicate_name"); + } + } +} + +namespace Dapper.FluentMap.Tests.MappingRegistrationScan.ExplicitThenScan +{ + public class Marker + { + } + + public class ExplicitScanEntity + { + public int Id { get; set; } + + public string Name { get; set; } + } + + public class ScannedExplicitEntityMap : EntityMap + { + public ScannedExplicitEntityMap() + { + Map(e => e.Name).ToColumn("scanned_name"); + } + } +} + +namespace Dapper.FluentMap.Tests.MappingRegistrationScan.InheritedScan +{ + public class Marker + { + } + + public class BaseScanEntity + { + public int Id { get; set; } + } + + public class DerivedScanEntity : BaseScanEntity + { + public string Name { get; set; } + } + + public class ADerivedScanMap : EntityMap + { + public ADerivedScanMap() + { + IncludeBase(); + Map(e => e.Name).ToColumn("derived_name"); + } + } + + public class ZBaseScanMap : EntityMap + { + public ZBaseScanMap() + { + Map(e => e.Id).ToColumn("base_id"); + } + } +} From bbf65f80f00f45c8c170396d4b683b668d6a26f2 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sun, 26 Jul 2026 10:08:04 -0300 Subject: [PATCH 06/20] feat: support immutable constructor mappings --- .../02-constructor-immutable-mapping.md | 207 +++++++ docs/sdd/etapa-3/decisions.md | 9 + docs/sdd/etapa-3/status.md | 2 +- src/Dapper.FluentMap/MappingRegistry.cs | 62 +- .../TypeMaps/ConstructorParameterMap.cs | 34 ++ .../TypeMaps/FluentConstructorTypeMap.cs | 120 ++++ .../TypeMaps/FluentConventionTypeMap.cs | 14 +- .../TypeMaps/FluentTypeMap.cs | 11 +- src/Dapper.FluentMap/TypeMaps/MultiTypeMap.cs | 15 + .../ConstructorMappingTests.cs | 550 ++++++++++++++++++ 10 files changed, 1001 insertions(+), 23 deletions(-) create mode 100644 docs/sdd/etapa-3/02-constructor-immutable-mapping.md create mode 100644 src/Dapper.FluentMap/TypeMaps/ConstructorParameterMap.cs create mode 100644 src/Dapper.FluentMap/TypeMaps/FluentConstructorTypeMap.cs create mode 100644 test/Dapper.FluentMap.Tests/ConstructorMappingTests.cs diff --git a/docs/sdd/etapa-3/02-constructor-immutable-mapping.md b/docs/sdd/etapa-3/02-constructor-immutable-mapping.md new file mode 100644 index 0000000..0da91e7 --- /dev/null +++ b/docs/sdd/etapa-3/02-constructor-immutable-mapping.md @@ -0,0 +1,207 @@ +# 02 - Constructor Mapping E Imutaveis + +## Specification + +Esta entrega melhora a integracao do FluentMap com constructor mapping do Dapper para modelos com construtores parametrizados, propriedades somente leitura, propriedades `init`, records e classes imutaveis. + +Objetivos tratados: + +- permitir que `Map(e => e.Name).ToColumn("full_name")` influencie parametros de construtor correspondentes; +- aplicar mappings explicitos, mappings herdados, conventions e naming policies tambem na selecao de construtor; +- preservar fallback do `DefaultTypeMap` quando nao houver configuracao relevante do FluentMap; +- manter a precedencia consolidada: mapping explicito do derivado -> mapping explicito herdado -> convention/naming policy -> Dapper default; +- validar materializacao real por `Dapper.QuerySingle` com SQLite in-memory. + +Fora do objetivo: + +- criar materializador concorrente ao Dapper; +- gerar IL proprio; +- criar object factory; +- adicionar DSL publica `MapConstructor(...)`; +- implementar nested object materialization ou Value Objects. + +## Discovery + +Arquivos analisados: + +- `AGENTS.md` +- `.agents/skills/run-tests/SKILL.md` +- `docs/sdd/etapa-1/README.md` +- `docs/sdd/etapa-1/decisions.md` +- `docs/sdd/etapa-2/README.md` +- `docs/sdd/etapa-2/decisions.md` +- `docs/sdd/etapa-3/README.md` +- `docs/sdd/etapa-3/status.md` +- `docs/sdd/etapa-3/decisions.md` +- `docs/sdd/etapa-3/01-mapping-registration.md` +- `src/Dapper.FluentMap/TypeMaps/MultiTypeMap.cs` +- `src/Dapper.FluentMap/TypeMaps/FluentTypeMap.cs` +- `src/Dapper.FluentMap/TypeMaps/FluentConventionTypeMap.cs` +- `src/Dapper.FluentMap/MappingRegistry.cs` +- `src/Dapper.FluentMap/Mapping/MemberPath.cs` +- testes de integracao, composition, inheritance e naming policies. + +Contratos do Dapper 2.1.79 analisados: + +- `SqlMapper.ITypeMap.FindConstructor(string[] names, Type[] types)`; +- `SqlMapper.ITypeMap.GetConstructorParameter(ConstructorInfo constructor, string columnName)`; +- `SqlMapper.IMemberMap.Parameter`; +- `DefaultTypeMap`; +- `CustomPropertyTypeMap`. + +Comportamento encontrado: + +- `FluentMapTypeMap` e `FluentConventionTypeMap` eram compostos por `CustomPropertyTypeMap` e `DefaultTypeMap`. +- `CustomPropertyTypeMap` resolve propriedades, mas nao fornece constructor mapping. +- `MultiTypeMap.FindConstructor` acabava delegando ao `DefaultTypeMap`. +- `MultiTypeMap.GetConstructorParameter` tambem dependia do `DefaultTypeMap`, mas o `CustomPropertyTypeMap` do Dapper 2.1.79 pode lancar `NotSupportedException` nesse metodo. +- `DefaultTypeMap.FindConstructor` seleciona construtor por nomes e tipos de colunas na ordem recebida do reader; construtor sem parametros vence cedo; construtores parametrizados precisam ter a mesma quantidade de parametros que as colunas consideradas. +- `DefaultTypeMap.GetConstructorParameter` associa coluna a parametro por nome do parametro, com matching case-insensitive e suporte ao flag global `MatchNamesWithUnderscores`. +- Mappings explicitos, herdados, conventions e naming policies do FluentMap ja influenciavam `GetMember`, mas nao os nomes usados por `FindConstructor`. +- Records posicionais e classes imutaveis falhavam quando a coluna configurada nao tinha o mesmo nome do parametro do construtor. +- Com SQLite, colunas inteiras sao expostas como `Int64`; para colunas mapeadas pelo FluentMap, a entrega usa o tipo da propriedade mapeada na chamada ao `DefaultTypeMap.FindConstructor`, preservando a conversao final do Dapper. + +Caracterizacao antes da alteracao: + +- `TraditionalPocoShouldContinueMaterializingConfiguredColumn` passava. +- `ParameterlessConstructorShouldContinueUsingSettableProperties` passava. +- `NestedMemberPathMappingShouldNotActAsConstructorParameterMapping` passava como falha esperada de materializacao. +- Falhavam records, classes imutaveis, explicit mappings para parametros, naming policy, convention, multiplos construtores, casing diferente, fallback parcial e inheritance, sempre porque o Dapper via nomes crus como `person_id` e `full_name`. + +## Decision + +A lacuna pertence ao FluentMap apenas na traducao de metadata: + +- coluna recebida do reader; +- propriedade simples configurada pelo FluentMap; +- nome e tipo que o `DefaultTypeMap` deve enxergar para escolher o construtor; +- `ParameterInfo` que o Dapper deve receber por `IMemberMap.Parameter`. + +A materializacao continua pertencendo ao Dapper. + +Estrategia: + +- adicionar um type map interno `FluentConstructorTypeMap`; +- inserir esse mapper antes de `CustomPropertyTypeMap` e antes de `DefaultTypeMap`; +- quando uma coluna resolve para um `IPropertyMap` simples e nao ignorado, chamar `DefaultTypeMap.FindConstructor` com nome e tipo da propriedade; +- quando uma coluna nao possui mapping simples, manter nome e tipo originais e deixar o `DefaultTypeMap` atuar como fallback; +- implementar um `IMemberMap` interno apenas para expor `ParameterInfo`; +- preservar `GetMember` existente para propriedades settable. + +Precedencia: + +1. mapping explicito do derivado; +2. mapping explicito herdado mais proximo; +3. demais mappings herdados; +4. convention/naming policy; +5. fallback do `DefaultTypeMap`. + +Inheritance: + +- constructor mapping usa a composicao efetiva ja existente no `MappingRegistry`; +- `IncludeBase()` continua opt-in; +- mappings herdados podem traduzir colunas para parametros do construtor do tipo derivado quando o parametro corresponde a propriedade simples herdada. + +Conflitos e ambiguidades: + +- conflitos de coluna dentro de entity map e convention continuam falhando cedo pelas validacoes da Etapa 2; +- ambiguidades de constructor overload continuam sob responsabilidade do algoritmo do Dapper; +- nenhum erro novo de ambiguidade de construtor foi criado nesta entrega. + +MemberPath: + +- constructor parameter nao e representado como `MemberPath`; +- somente mappings cujo `MemberPath` nao e aninhado participam do constructor mapping; +- mapping como `Map(e => e.Rank.Level).ToColumn("rank_level")` nao e usado para preencher parametro `level` do construtor raiz; +- nested object materialization e Value Objects permanecem fora do contrato. + +Records e `init`: + +- records posicionais funcionam porque seus parametros correspondem a propriedades simples; +- propriedades `init` continuam sendo tratadas pelo Dapper conforme seu proprio suporte a setter/constructor; +- esta entrega nao adiciona API nem regra especial para `init`. + +Parametros opcionais: + +- nao foi criada regra especial para parametros opcionais; +- a selecao segue `DefaultTypeMap`: construtor parametrizado precisa corresponder a assinatura esperada pelo Dapper. + +## Delivery + +Arquivos alterados: + +- `src/Dapper.FluentMap/MappingRegistry.cs` +- `src/Dapper.FluentMap/TypeMaps/ConstructorParameterMap.cs` +- `src/Dapper.FluentMap/TypeMaps/FluentConstructorTypeMap.cs` +- `src/Dapper.FluentMap/TypeMaps/FluentConventionTypeMap.cs` +- `src/Dapper.FluentMap/TypeMaps/FluentTypeMap.cs` +- `src/Dapper.FluentMap/TypeMaps/MultiTypeMap.cs` +- `test/Dapper.FluentMap.Tests/ConstructorMappingTests.cs` +- `docs/sdd/etapa-3/02-constructor-immutable-mapping.md` +- `docs/sdd/etapa-3/decisions.md` +- `docs/sdd/etapa-3/status.md` + +Implementacao: + +- `MappingRegistry` passou a expor resolucao interna de `IPropertyMap`, reaproveitando o cache estruturado existente. +- `FluentConstructorTypeMap` traduz colunas mapeadas para nomes/tipos de propriedades simples e delega a selecao ao `DefaultTypeMap`. +- `ConstructorParameterMap` implementa `SqlMapper.IMemberMap` para fornecer `ParameterInfo` ao Dapper. +- `MultiTypeMap.GetConstructorParameter` passou a ignorar `NotSupportedException` de mappers que nao suportam constructor parameter mapping, permitindo fallback real. +- `FluentMapTypeMap` e `FluentConventionTypeMap` passaram a compor o mapper de construtor antes do mapper de propriedades. + +Testes adicionados cobrem: + +- POCO tradicional; +- record posicional; +- classe imutavel; +- mapping explicito para parametro; +- naming policy para parametro; +- convention para parametro; +- construtor unico; +- multiplos construtores; +- construtor sem parametros; +- casing diferente; +- parameter mapping com fallback Dapper; +- mapping herdado; +- nested `MemberPath` nao usado como parametro de construtor; +- materializacao real via SQLite in-memory. + +## Validation + +Ambiente: + +- SDK: `10.0.302` +- test runner detectado: VSTest com xUnit v3 +- projeto principal: `netstandard2.0` +- projeto de testes do core: `net10.0` + +Validacao localizada: + +- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~ConstructorMappingTests"` + - antes da implementacao: 8 falhas e 3 sucessos, reproduzindo a lacuna. + - depois da implementacao: sucesso, 11 testes aprovados. + +Validacao final: + +- `dotnet restore` + - resultado: sucesso. +- `dotnet build` + - resultado: sucesso, 0 warnings, 0 erros. +- `dotnet test` + - resultado: sucesso, 117 testes aprovados no core e 7 testes aprovados no Dommel. +- `dotnet build --configuration Release` + - resultado: sucesso, 0 warnings, 0 erros. +- `dotnet test --configuration Release` + - resultado: sucesso, 117 testes aprovados no core e 7 testes aprovados no Dommel. +- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --filter "Category=Integration"` + - resultado: sucesso, 21 testes de integracao aprovados. + +`dotnet pack` nao foi executado porque nao houve mudanca de empacotamento, metadados NuGet ou targets. + +## Limitacoes + +- Constructor overload ambiguity continua seguindo o Dapper. +- Parametros opcionais nao recebem tratamento especial. +- Nested object materialization e Value Objects continuam fora do contrato. +- O suporte AOT/trimming nao foi ampliado. +- `DefaultTypeMap.MatchNamesWithUnderscores` continua sendo flag global do Dapper e nao e alterado por naming policies do FluentMap. diff --git a/docs/sdd/etapa-3/decisions.md b/docs/sdd/etapa-3/decisions.md index 9385f7a..3fe47f7 100644 --- a/docs/sdd/etapa-3/decisions.md +++ b/docs/sdd/etapa-3/decisions.md @@ -12,3 +12,12 @@ Registre aqui apenas decisoes que afetem entregas posteriores. - O scanning aceita filtros opcionais de namespace, ordena candidatos de forma deterministica e registra maps base incluidos antes dos derivados quando ambos sao descobertos juntos. - Duplicidade de entidade, seja por registro explicito, scanning ou combinacao dos dois, e erro de configuracao; nao ha comportamento "ultimo ganha". - Reflection restante desta entrega: inferencia de entidade via `IEntityMap`, scanning por assembly, `Activator.CreateInstance` para criar maps descobertos e criacao interna de `FluentMapTypeMap<>` no `MappingRegistry`. AOT/trimming completo permanece fora do contrato atual. + +## Constructor Mapping E Imutaveis + +- Constructor mapping do FluentMap deve traduzir metadata para o Dapper, nao materializar objetos diretamente. +- Mappings explicitos, mappings herdados, conventions e naming policies influenciam constructor selection e `IMemberMap.Parameter` quando resolvem para propriedade simples e nao ignorada. +- A selecao de construtor continua delegada ao `DefaultTypeMap`; ambiguidades e parametros opcionais seguem o comportamento do Dapper. +- A precedencia consolidada tambem vale para parametros de construtor: explicit derivado -> explicit herdado -> convention/naming policy -> Dapper default. +- Constructor parameters nao sao `MemberPath`; mappings aninhados nao participam de constructor mapping nem implicam suporte a nested object materialization. +- Records posicionais e classes imutaveis passam a funcionar quando seus parametros correspondem a propriedades simples mapeadas ou resolvidas pelo fallback do Dapper. diff --git a/docs/sdd/etapa-3/status.md b/docs/sdd/etapa-3/status.md index eb7b7ab..7e9cd5f 100644 --- a/docs/sdd/etapa-3/status.md +++ b/docs/sdd/etapa-3/status.md @@ -3,5 +3,5 @@ | Entrega | Status | Commit | |---|---|---| | 01 - Registro e descoberta de mappings | Concluido | feat: modernize mapping registration | -| 02 - Constructor mapping e imutaveis | Pendente | - | +| 02 - Constructor mapping e imutaveis | Concluido | feat: support immutable constructor mappings | | 03 - Validate e Explain | Pendente | - | diff --git a/src/Dapper.FluentMap/MappingRegistry.cs b/src/Dapper.FluentMap/MappingRegistry.cs index a8490c9..5061425 100644 --- a/src/Dapper.FluentMap/MappingRegistry.cs +++ b/src/Dapper.FluentMap/MappingRegistry.cs @@ -100,7 +100,7 @@ internal PropertyInfo GetFluentPropertyInfo(Type type, string columnName) { var cacheKey = MappingCacheKey.FluentMap(type, columnName); return _propertyMapCache - .GetOrAdd(cacheKey, _ => new MappingCacheEntry(ResolveFluentPropertyInfo(type, columnName))) + .GetOrAdd(cacheKey, _ => new MappingCacheEntry(ResolveFluentPropertyMap(type, columnName))) .PropertyInfo; } @@ -108,10 +108,26 @@ internal PropertyInfo GetConventionPropertyInfo(Type type, string columnName) { var cacheKey = MappingCacheKey.ConventionOnly(type, columnName); return _propertyMapCache - .GetOrAdd(cacheKey, _ => new MappingCacheEntry(ResolveConventionPropertyInfo(type, columnName))) + .GetOrAdd(cacheKey, _ => new MappingCacheEntry(ResolveConventionPropertyMap(type, columnName))) .PropertyInfo; } + internal IPropertyMap GetFluentPropertyMap(Type type, string columnName) + { + var cacheKey = MappingCacheKey.FluentMap(type, columnName); + return _propertyMapCache + .GetOrAdd(cacheKey, _ => new MappingCacheEntry(ResolveFluentPropertyMap(type, columnName))) + .PropertyMap; + } + + internal IPropertyMap GetConventionPropertyMap(Type type, string columnName) + { + var cacheKey = MappingCacheKey.ConventionOnly(type, columnName); + return _propertyMapCache + .GetOrAdd(cacheKey, _ => new MappingCacheEntry(ResolveConventionPropertyMap(type, columnName))) + .PropertyMap; + } + internal void Reset(params Type[] dapperTypes) { EntityMaps.Clear(); @@ -143,24 +159,17 @@ private void InvalidateType(Type type) } } - private PropertyInfo ResolveFluentPropertyInfo(Type type, string columnName) + private IPropertyMap ResolveFluentPropertyMap(Type type, string columnName) { var explicitPropertyMaps = GetExplicitPropertyMaps(type); var explicitPropertyMap = explicitPropertyMaps.FirstOrDefault(m => MatchColumnNames(m, columnName)); if (explicitPropertyMap != null) { - if (!explicitPropertyMap.Ignored) - { - return explicitPropertyMap.PropertyInfo; - } - -#if !NETSTANDARD1_3 - return new IgnoredPropertyInfo(); -#endif + return explicitPropertyMap; } - return ResolveConventionPropertyInfo(type, columnName, explicitPropertyMaps); + return ResolveConventionPropertyMap(type, columnName, explicitPropertyMaps); } private IList GetExplicitPropertyMaps(Type type) @@ -235,12 +244,12 @@ private static IList GetIncludedBaseTypes(IEntityMap entityMap) return mapWithIncludedBases.IncludedBaseTypes; } - private PropertyInfo ResolveConventionPropertyInfo(Type type, string columnName) + private IPropertyMap ResolveConventionPropertyMap(Type type, string columnName) { - return ResolveConventionPropertyInfo(type, columnName, new IPropertyMap[0]); + return ResolveConventionPropertyMap(type, columnName, new IPropertyMap[0]); } - private PropertyInfo ResolveConventionPropertyInfo(Type type, string columnName, IList explicitPropertyMaps) + private IPropertyMap ResolveConventionPropertyMap(Type type, string columnName, IList explicitPropertyMaps) { if (!TypeConventions.TryGetValue(type, out var conventions)) { @@ -272,7 +281,7 @@ private PropertyInfo ResolveConventionPropertyInfo(Type type, string columnName, continue; } - return maps[0].PropertyInfo; + return maps[0]; } return null; @@ -295,11 +304,28 @@ private static bool MatchColumnNames(IPropertyMap map, string columnName) private sealed class MappingCacheEntry { - internal MappingCacheEntry(PropertyInfo propertyInfo) + internal MappingCacheEntry(IPropertyMap propertyMap) { - PropertyInfo = propertyInfo; + PropertyMap = propertyMap; + + if (propertyMap == null) + { + return; + } + + if (!propertyMap.Ignored) + { + PropertyInfo = propertyMap.PropertyInfo; + return; + } + +#if !NETSTANDARD1_3 + PropertyInfo = new IgnoredPropertyInfo(); +#endif } + internal IPropertyMap PropertyMap { get; } + internal PropertyInfo PropertyInfo { get; } } } diff --git a/src/Dapper.FluentMap/TypeMaps/ConstructorParameterMap.cs b/src/Dapper.FluentMap/TypeMaps/ConstructorParameterMap.cs new file mode 100644 index 0000000..e216430 --- /dev/null +++ b/src/Dapper.FluentMap/TypeMaps/ConstructorParameterMap.cs @@ -0,0 +1,34 @@ +using System; +using System.Reflection; + +namespace Dapper.FluentMap.TypeMaps +{ + internal sealed class ConstructorParameterMap : SqlMapper.IMemberMap + { + internal ConstructorParameterMap(string columnName, ParameterInfo parameter) + { + if (columnName == null) + { + throw new ArgumentNullException(nameof(columnName)); + } + + if (parameter == null) + { + throw new ArgumentNullException(nameof(parameter)); + } + + ColumnName = columnName; + Parameter = parameter; + } + + public string ColumnName { get; } + + public Type MemberType => Parameter.ParameterType; + + public PropertyInfo Property => null; + + public FieldInfo Field => null; + + public ParameterInfo Parameter { get; } + } +} diff --git a/src/Dapper.FluentMap/TypeMaps/FluentConstructorTypeMap.cs b/src/Dapper.FluentMap/TypeMaps/FluentConstructorTypeMap.cs new file mode 100644 index 0000000..3a95e5a --- /dev/null +++ b/src/Dapper.FluentMap/TypeMaps/FluentConstructorTypeMap.cs @@ -0,0 +1,120 @@ +using System; +using System.Linq; +using System.Reflection; +using Dapper.FluentMap.Mapping; + +namespace Dapper.FluentMap.TypeMaps +{ + internal sealed class FluentConstructorTypeMap : SqlMapper.ITypeMap + { + private readonly Type _type; + private readonly Func _propertyMapResolver; + private readonly DefaultTypeMap _defaultTypeMap; + + internal FluentConstructorTypeMap(Type type, Func propertyMapResolver) + { + if (type == null) + { + throw new ArgumentNullException(nameof(type)); + } + + if (propertyMapResolver == null) + { + throw new ArgumentNullException(nameof(propertyMapResolver)); + } + + _type = type; + _propertyMapResolver = propertyMapResolver; + _defaultTypeMap = new DefaultTypeMap(type); + } + + public ConstructorInfo FindConstructor(string[] names, Type[] types) + { + var effectiveNames = new string[names.Length]; + var effectiveTypes = new Type[types.Length]; + var hasMappedColumn = false; + + for (var i = 0; i < names.Length; i++) + { + var map = GetSimplePropertyMap(names[i]); + + if (map != null && !map.Ignored) + { + effectiveNames[i] = map.PropertyInfo.Name; + effectiveTypes[i] = map.PropertyInfo.PropertyType; + hasMappedColumn = true; + continue; + } + + effectiveNames[i] = names[i]; + effectiveTypes[i] = types[i]; + } + + return hasMappedColumn + ? _defaultTypeMap.FindConstructor(effectiveNames, effectiveTypes) + : null; + } + + public ConstructorInfo FindExplicitConstructor() + { + return null; + } + + public SqlMapper.IMemberMap GetConstructorParameter(ConstructorInfo constructor, string columnName) + { + var map = GetSimplePropertyMap(columnName); + if (map == null || map.Ignored) + { + return null; + } + + var parameter = MatchParameter(constructor.GetParameters(), map.PropertyInfo.Name); + return parameter == null + ? null + : new ConstructorParameterMap(columnName, parameter); + } + + public SqlMapper.IMemberMap GetMember(string columnName) + { + return null; + } + + private IPropertyMap GetSimplePropertyMap(string columnName) + { + var map = _propertyMapResolver(_type, columnName); + if (map == null) + { + return null; + } + + var memberPath = PropertyMapIdentity.GetMemberPath(map); + return memberPath.IsNested ? null : map; + } + + private static ParameterInfo MatchParameter(ParameterInfo[] parameters, string memberName) + { + return parameters.FirstOrDefault(p => string.Equals(p.Name, memberName, StringComparison.Ordinal)) + ?? parameters.FirstOrDefault(p => string.Equals(p.Name, memberName, StringComparison.OrdinalIgnoreCase)) + ?? MatchParameterWithUnderscores(parameters, memberName); + } + + private static ParameterInfo MatchParameterWithUnderscores(ParameterInfo[] parameters, string memberName) + { + if (!DefaultTypeMap.MatchNamesWithUnderscores) + { + return null; + } + + var effectiveMemberName = memberName.Replace("_", string.Empty); + return parameters.FirstOrDefault(p => string.Equals(p.Name, effectiveMemberName, StringComparison.Ordinal)) + ?? parameters.FirstOrDefault(p => string.Equals(p.Name, effectiveMemberName, StringComparison.OrdinalIgnoreCase)) + ?? parameters.FirstOrDefault(p => string.Equals(RemoveUnderscores(p.Name), effectiveMemberName, StringComparison.Ordinal)) + ?? parameters.FirstOrDefault(p => string.Equals(RemoveUnderscores(p.Name), effectiveMemberName, StringComparison.OrdinalIgnoreCase)); + } + + private static string RemoveUnderscores(string value) + { + return value == null ? null : value.Replace("_", string.Empty); + } + } +} diff --git a/src/Dapper.FluentMap/TypeMaps/FluentConventionTypeMap.cs b/src/Dapper.FluentMap/TypeMaps/FluentConventionTypeMap.cs index 2dd7855..b126c14 100644 --- a/src/Dapper.FluentMap/TypeMaps/FluentConventionTypeMap.cs +++ b/src/Dapper.FluentMap/TypeMaps/FluentConventionTypeMap.cs @@ -1,5 +1,6 @@ -using System; +using System; using System.Reflection; +using Dapper.FluentMap.Mapping; namespace Dapper.FluentMap.TypeMaps { @@ -17,14 +18,21 @@ public class FluentConventionTypeMap : MultiTypeMap /// as mapping strategies. /// public FluentConventionTypeMap() - : base(new CustomPropertyTypeMap(typeof(TEntity), GetPropertyInfo), new DefaultTypeMap(typeof(TEntity))) + : base( + new FluentConstructorTypeMap(typeof(TEntity), GetPropertyMap), + new CustomPropertyTypeMap(typeof(TEntity), GetPropertyInfo), + new DefaultTypeMap(typeof(TEntity))) { } + private static IPropertyMap GetPropertyMap(Type type, string columnName) + { + return FluentMapper.Registry.GetConventionPropertyMap(type, columnName); + } + private static PropertyInfo GetPropertyInfo(Type type, string columnName) { return FluentMapper.Registry.GetConventionPropertyInfo(type, columnName); } - } } diff --git a/src/Dapper.FluentMap/TypeMaps/FluentTypeMap.cs b/src/Dapper.FluentMap/TypeMaps/FluentTypeMap.cs index f6a37d9..0c7014c 100644 --- a/src/Dapper.FluentMap/TypeMaps/FluentTypeMap.cs +++ b/src/Dapper.FluentMap/TypeMaps/FluentTypeMap.cs @@ -1,5 +1,6 @@ using System; using System.Reflection; +using Dapper.FluentMap.Mapping; namespace Dapper.FluentMap.TypeMaps { @@ -16,10 +17,18 @@ public class FluentMapTypeMap : MultiTypeMap /// as mapping strategies. /// public FluentMapTypeMap() - : base(new CustomPropertyTypeMap(typeof(TEntity), GetPropertyInfo), new DefaultTypeMap(typeof(TEntity))) + : base( + new FluentConstructorTypeMap(typeof(TEntity), GetPropertyMap), + new CustomPropertyTypeMap(typeof(TEntity), GetPropertyInfo), + new DefaultTypeMap(typeof(TEntity))) { } + private static IPropertyMap GetPropertyMap(Type type, string columnName) + { + return FluentMapper.Registry.GetFluentPropertyMap(type, columnName); + } + private static PropertyInfo GetPropertyInfo(Type type, string columnName) { return FluentMapper.Registry.GetFluentPropertyInfo(type, columnName); diff --git a/src/Dapper.FluentMap/TypeMaps/MultiTypeMap.cs b/src/Dapper.FluentMap/TypeMaps/MultiTypeMap.cs index 6657840..7dd3c22 100644 --- a/src/Dapper.FluentMap/TypeMaps/MultiTypeMap.cs +++ b/src/Dapper.FluentMap/TypeMaps/MultiTypeMap.cs @@ -42,6 +42,11 @@ public ConstructorInfo FindConstructor(string[] names, Type[] types) // Ignore NotImplementedException's thrown by the CustomPropertyTypeMap // and continue to the next mapping strategy. } + catch (NotSupportedException) + { + // Ignore NotSupportedException's thrown by the CustomPropertyTypeMap + // and continue to the next mapping strategy. + } } return null; @@ -65,6 +70,11 @@ public ConstructorInfo FindExplicitConstructor() // Ignore NotImplementedException's thrown by the CustomPropertyTypeMap // and continue to the next mapping strategy. } + catch (NotSupportedException) + { + // Ignore NotSupportedException's thrown by the CustomPropertyTypeMap + // and continue to the next mapping strategy. + } } return null; @@ -89,6 +99,11 @@ public SqlMapper.IMemberMap GetConstructorParameter(ConstructorInfo constructor, // Ignore NotImplementedException's thrown by the CustomPropertyTypeMap // and continue to the next mapping strategy. } + catch (NotSupportedException) + { + // Ignore NotSupportedException's thrown by the CustomPropertyTypeMap + // and continue to the next mapping strategy. + } } return null; diff --git a/test/Dapper.FluentMap.Tests/ConstructorMappingTests.cs b/test/Dapper.FluentMap.Tests/ConstructorMappingTests.cs new file mode 100644 index 0000000..0556b4c --- /dev/null +++ b/test/Dapper.FluentMap.Tests/ConstructorMappingTests.cs @@ -0,0 +1,550 @@ +using System; +using Dapper; +using Dapper.FluentMap.Conventions; +using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.Naming; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class ConstructorMappingTests + { + [Fact] + [Trait("Category", "Integration")] + public void TraditionalPocoShouldContinueMaterializingConfiguredColumn() + { + PreTest(typeof(TraditionalPoco)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new TraditionalPocoMap())); + + using (var connection = OpenConnection()) + { + var entity = connection.QuerySingle( + "SELECT 1 AS person_id, 'Ada' AS Name;"); + + Assert.Equal(1, entity.Id); + Assert.Equal("Ada", entity.Name); + } + } + finally + { + PreTest(typeof(TraditionalPoco)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void PositionalRecordShouldMaterializeExplicitColumns() + { + PreTest(typeof(ExplicitRecord)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new ExplicitRecordMap())); + + using (var connection = OpenConnection()) + { + var entity = connection.QuerySingle( + "SELECT 2 AS person_id, 'Grace Hopper' AS full_name;"); + + Assert.Equal(2, entity.Id); + Assert.Equal("Grace Hopper", entity.FullName); + } + } + finally + { + PreTest(typeof(ExplicitRecord)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void ImmutableClassShouldMaterializeExplicitColumns() + { + PreTest(typeof(ExplicitImmutableCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new ExplicitImmutableCustomerMap())); + + using (var connection = OpenConnection()) + { + var entity = connection.QuerySingle( + "SELECT 3 AS person_id, 'Katherine Johnson' AS full_name;"); + + Assert.Equal(3, entity.Id); + Assert.Equal("Katherine Johnson", entity.FullName); + } + } + finally + { + PreTest(typeof(ExplicitImmutableCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void NamingPolicyShouldMaterializeConstructorParameters() + { + PreTest(typeof(PolicyImmutableCustomer)); + + try + { + FluentMapper.Initialize(c => c.UseNamingPolicy(NamingPolicy.SnakeCase).ForEntity()); + + using (var connection = OpenConnection()) + { + var entity = connection.QuerySingle( + "SELECT 4 AS customer_id, 'Barbara Liskov' AS full_name;"); + + Assert.Equal(4, entity.CustomerId); + Assert.Equal("Barbara Liskov", entity.FullName); + } + } + finally + { + PreTest(typeof(PolicyImmutableCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void ConventionShouldMaterializeConstructorParameters() + { + PreTest(typeof(ConventionImmutableCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddConvention().ForEntity()); + + using (var connection = OpenConnection()) + { + var entity = connection.QuerySingle( + "SELECT 5 AS colId, 'Margaret Hamilton' AS colName;"); + + Assert.Equal(5, entity.Id); + Assert.Equal("Margaret Hamilton", entity.Name); + } + } + finally + { + PreTest(typeof(ConventionImmutableCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void MultipleConstructorsShouldUseMappedNamesForDapperSelection() + { + PreTest(typeof(MultipleConstructorCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new MultipleConstructorCustomerMap())); + + using (var connection = OpenConnection()) + { + var entity = connection.QuerySingle( + "SELECT 6 AS person_id, 'Anita Borg' AS full_name;"); + + Assert.Equal(6, entity.Id); + Assert.Equal("Anita Borg", entity.FullName); + Assert.Equal("id-name", entity.ConstructorUsed); + } + } + finally + { + PreTest(typeof(MultipleConstructorCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void ParameterlessConstructorShouldContinueUsingSettableProperties() + { + PreTest(typeof(ParameterlessAndSettableCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new ParameterlessAndSettableCustomerMap())); + + using (var connection = OpenConnection()) + { + var entity = connection.QuerySingle( + "SELECT 7 AS person_id, 'Joan Clarke' AS full_name;"); + + Assert.Equal(7, entity.Id); + Assert.Equal("Joan Clarke", entity.FullName); + Assert.Equal("parameterless", entity.ConstructorUsed); + } + } + finally + { + PreTest(typeof(ParameterlessAndSettableCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void CaseInsensitiveExplicitMappingShouldMaterializeConstructorParameter() + { + PreTest(typeof(CaseInsensitiveConstructorCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CaseInsensitiveConstructorCustomerMap())); + + using (var connection = OpenConnection()) + { + var entity = connection.QuerySingle( + "SELECT 8 AS PERSON_ID;"); + + Assert.Equal(8, entity.Id); + } + } + finally + { + PreTest(typeof(CaseInsensitiveConstructorCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void ConstructorParameterMappingShouldFallbackToDapperDefault() + { + PreTest(typeof(PartialExplicitConstructorCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new PartialExplicitConstructorCustomerMap())); + + using (var connection = OpenConnection()) + { + var entity = connection.QuerySingle( + "SELECT 9 AS Id, 'Radia Perlman' AS full_name;"); + + Assert.Equal(9L, entity.Id); + Assert.Equal("Radia Perlman", entity.FullName); + } + } + finally + { + PreTest(typeof(PartialExplicitConstructorCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void IncludedBaseMappingShouldMaterializeConstructorParameter() + { + PreTest(typeof(ImmutableBaseCustomer), typeof(ImmutableDerivedCustomer)); + + try + { + FluentMapper.Initialize(c => + { + c.AddMap(new ImmutableBaseCustomerMap()); + c.AddMap(new ImmutableDerivedCustomerMap()); + }); + + using (var connection = OpenConnection()) + { + var entity = connection.QuerySingle( + "SELECT 10 AS person_id, 'Evelyn Boyd Granville' AS Name;"); + + Assert.Equal(10, entity.Id); + Assert.Equal("Evelyn Boyd Granville", entity.Name); + } + } + finally + { + PreTest(typeof(ImmutableBaseCustomer), typeof(ImmutableDerivedCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void NestedMemberPathMappingShouldNotActAsConstructorParameterMapping() + { + PreTest(typeof(NestedPathConstructorCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new NestedPathConstructorCustomerMap())); + + using (var connection = OpenConnection()) + { + var exception = Assert.Throws(() => + connection.QuerySingle( + "SELECT 11 AS rank_level;")); + + Assert.Contains("constructor", exception.Message); + } + } + finally + { + PreTest(typeof(NestedPathConstructorCustomer)); + } + } + + private static SqliteConnection OpenConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + + private static void PreTest(params System.Type[] types) + { + FluentMapper.Reset(types); + } + + private class TraditionalPoco + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private class TraditionalPocoMap : EntityMap + { + public TraditionalPocoMap() + { + Map(e => e.Id).ToColumn("person_id"); + } + } + + private sealed record ExplicitRecord(int Id, string FullName); + + private class ExplicitRecordMap : EntityMap + { + public ExplicitRecordMap() + { + Map(e => e.Id).ToColumn("person_id"); + Map(e => e.FullName).ToColumn("full_name"); + } + } + + private sealed class ExplicitImmutableCustomer + { + public ExplicitImmutableCustomer(int id, string fullName) + { + Id = id; + FullName = fullName; + } + + public int Id { get; } + + public string FullName { get; } + } + + private class ExplicitImmutableCustomerMap : EntityMap + { + public ExplicitImmutableCustomerMap() + { + Map(e => e.Id).ToColumn("person_id"); + Map(e => e.FullName).ToColumn("full_name"); + } + } + + private sealed class PolicyImmutableCustomer + { + public PolicyImmutableCustomer(int customerId, string fullName) + { + CustomerId = customerId; + FullName = fullName; + } + + public int CustomerId { get; } + + public string FullName { get; } + } + + private sealed class ConventionImmutableCustomer + { + public ConventionImmutableCustomer(int id, string name) + { + Id = id; + Name = name; + } + + public int Id { get; } + + public string Name { get; } + } + + private sealed class MultipleConstructorCustomer + { + public MultipleConstructorCustomer(int id) + { + Id = id; + ConstructorUsed = "id"; + } + + public MultipleConstructorCustomer(int id, string fullName) + { + Id = id; + FullName = fullName; + ConstructorUsed = "id-name"; + } + + public int Id { get; } + + public string FullName { get; } + + public string ConstructorUsed { get; } + } + + private class MultipleConstructorCustomerMap : EntityMap + { + public MultipleConstructorCustomerMap() + { + Map(e => e.Id).ToColumn("person_id"); + Map(e => e.FullName).ToColumn("full_name"); + } + } + + private sealed class ParameterlessAndSettableCustomer + { + public ParameterlessAndSettableCustomer() + { + ConstructorUsed = "parameterless"; + } + + public ParameterlessAndSettableCustomer(int id, string fullName) + { + Id = id; + FullName = fullName; + ConstructorUsed = "id-name"; + } + + public int Id { get; set; } + + public string FullName { get; set; } + + public string ConstructorUsed { get; } + } + + private class ParameterlessAndSettableCustomerMap : EntityMap + { + public ParameterlessAndSettableCustomerMap() + { + Map(e => e.Id).ToColumn("person_id"); + Map(e => e.FullName).ToColumn("full_name"); + } + } + + private sealed class CaseInsensitiveConstructorCustomer + { + public CaseInsensitiveConstructorCustomer(int id) + { + Id = id; + } + + public int Id { get; } + } + + private class CaseInsensitiveConstructorCustomerMap : EntityMap + { + public CaseInsensitiveConstructorCustomerMap() + { + Map(e => e.Id).ToColumn("person_id", caseSensitive: false); + } + } + + private sealed class PartialExplicitConstructorCustomer + { + public PartialExplicitConstructorCustomer(long id, string fullName) + { + Id = id; + FullName = fullName; + } + + public long Id { get; } + + public string FullName { get; } + } + + private class PartialExplicitConstructorCustomerMap : EntityMap + { + public PartialExplicitConstructorCustomerMap() + { + Map(e => e.FullName).ToColumn("full_name"); + } + } + + private class ImmutableBaseCustomer + { + public ImmutableBaseCustomer(int id) + { + Id = id; + } + + public int Id { get; } + } + + private sealed class ImmutableDerivedCustomer : ImmutableBaseCustomer + { + public ImmutableDerivedCustomer(int id, string name) + : base(id) + { + Name = name; + } + + public string Name { get; } + } + + private class ImmutableBaseCustomerMap : EntityMap + { + public ImmutableBaseCustomerMap() + { + Map(e => e.Id).ToColumn("person_id"); + } + } + + private class ImmutableDerivedCustomerMap : EntityMap + { + public ImmutableDerivedCustomerMap() + { + IncludeBase(); + } + } + + private sealed class NestedPathConstructorCustomer + { + public NestedPathConstructorCustomer(int level) + { + Level = level; + } + + public int Level { get; } + + public RankInfo Rank { get; set; } + } + + private sealed class RankInfo + { + public int Level { get; set; } + } + + private class NestedPathConstructorCustomerMap : EntityMap + { + public NestedPathConstructorCustomerMap() + { + Map(e => e.Rank.Level).ToColumn("rank_level"); + } + } + + private class PrefixConvention : Convention + { + public PrefixConvention() + { + Properties() + .Configure(c => c.HasPrefix("col")); + } + } + } +} From 31ad620c74c3b20cc97960ad2e83f04df5f094f3 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sun, 26 Jul 2026 10:21:35 -0300 Subject: [PATCH 07/20] feat: add mapping diagnostics API --- docs/sdd/etapa-3/03-diagnostics-api.md | 324 +++++++++++++ docs/sdd/etapa-3/README.md | 32 ++ docs/sdd/etapa-3/decisions.md | 13 + docs/sdd/etapa-3/status.md | 2 +- .../ConstructorParameterExplanation.cs | 43 ++ .../Diagnostics/MappingExplanation.cs | 81 ++++ .../Diagnostics/MappingSource.cs | 33 ++ .../Diagnostics/MemberMappingExplanation.cs | 92 ++++ src/Dapper.FluentMap/FluentMapper.cs | 22 + src/Dapper.FluentMap/MappingRegistry.cs | 277 ++++++++++- .../TypeMaps/FluentConstructorTypeMap.cs | 2 +- .../DiagnosticsApiTests.cs | 433 ++++++++++++++++++ 12 files changed, 1344 insertions(+), 10 deletions(-) create mode 100644 docs/sdd/etapa-3/03-diagnostics-api.md create mode 100644 src/Dapper.FluentMap/Diagnostics/ConstructorParameterExplanation.cs create mode 100644 src/Dapper.FluentMap/Diagnostics/MappingExplanation.cs create mode 100644 src/Dapper.FluentMap/Diagnostics/MappingSource.cs create mode 100644 src/Dapper.FluentMap/Diagnostics/MemberMappingExplanation.cs create mode 100644 test/Dapper.FluentMap.Tests/DiagnosticsApiTests.cs diff --git a/docs/sdd/etapa-3/03-diagnostics-api.md b/docs/sdd/etapa-3/03-diagnostics-api.md new file mode 100644 index 0000000..26a79ff --- /dev/null +++ b/docs/sdd/etapa-3/03-diagnostics-api.md @@ -0,0 +1,324 @@ +# 03 - Validate E Explain + +## Specification + +Consolidar diagnosticos de configuracao para o `Dapper.FluentMap` apos a introducao de registro moderno, mappings herdados, naming policies e constructor mapping. + +Objetivos tratados: + +- expor validacao publica para o estado global atual; +- agregar erros quando o estado configurado contem mais de uma falha; +- explicar mappings efetivos por entidade sem retornar apenas texto; +- representar origem do mapping de forma estruturada; +- incluir explicit mapping, inherited mapping, convention, naming policy, fallback do Dapper e constructor parameter; +- preservar caches, registry e type maps do Dapper sem side effects de diagnostico. + +Fora do objetivo: + +- logging; +- acesso a banco; +- I/O; +- alteracao de comportamento de materializacao; +- sistema de profiles; +- diagnostico query-specific. + +## Discovery + +Arquivos analisados: + +- `AGENTS.md` +- `.agents/skills/run-tests/SKILL.md` +- `docs/sdd/etapa-1/README.md` +- `docs/sdd/etapa-1/decisions.md` +- `docs/sdd/etapa-2/README.md` +- `docs/sdd/etapa-2/decisions.md` +- `docs/sdd/etapa-2/02-configuration-validation.md` +- `docs/sdd/etapa-3/README.md` +- `docs/sdd/etapa-3/status.md` +- `docs/sdd/etapa-3/decisions.md` +- `docs/sdd/etapa-3/01-mapping-registration.md` +- `docs/sdd/etapa-3/02-constructor-immutable-mapping.md` +- `src/Dapper.FluentMap/FluentMapper.cs` +- `src/Dapper.FluentMap/MappingRegistry.cs` +- `src/Dapper.FluentMap/MappingConfigurationValidator.cs` +- `src/Dapper.FluentMap/Mapping/EntityMap.cs` +- `src/Dapper.FluentMap/Mapping/PropertyMap.cs` +- `src/Dapper.FluentMap/Mapping/MemberPath.cs` +- `src/Dapper.FluentMap/Mapping/PropertyMapIdentity.cs` +- `src/Dapper.FluentMap/Conventions/*` +- `src/Dapper.FluentMap/TypeMaps/*` +- testes existentes de validacao, composicao, inheritance, naming policies e constructor mapping. + +Estado anterior da validacao: + +- a Etapa 2 criou `FluentMapConfigurationException` e `MappingConfigurationValidator`; +- a validacao era fail-fast durante construcao ou registro; +- a decisao documentada na Etapa 2 foi nao expor `Validate()`, porque ainda nao havia modelo agregado ou API de diagnostico; +- nenhuma API `Explain()` existia. + +Pipeline mapeado: + +```text +FluentMapper.Initialize +↓ +FluentMapConfiguration +↓ +MappingRegistry +↓ +MappingConfigurationValidator +↓ +entity maps + included base maps + conventions/naming policies +↓ +FluentMapTypeMap +↓ +FluentConstructorTypeMap + CustomPropertyTypeMap + DefaultTypeMap +↓ +constructor parameter/property member/fallback +``` + +Informacoes disponiveis: + +- `EntityMaps` guarda entity maps registrados por entidade; +- `IEntityMapWithIncludedBaseTypes` preserva bases incluidas; +- `PropertyMapIdentity` preserva `MemberPath` completo; +- conventions e naming policies produzem `PropertyMap` no registro; +- `NamingPolicyConvention` permite distinguir naming policy de convention comum; +- constructor mapping ja possui algoritmo interno para associar propriedade simples a parametro; +- fallback do Dapper pode ser explicado por propriedades publicas simples que nao possuem mapping efetivo do FluentMap. + +Informacao descartada: + +- a resolucao hot path retornava apenas `IPropertyMap`/`PropertyInfo`, sem provenance; +- a provenance pode ser reconstruida sem duplicar estado usando a composicao existente do registry. + +## Decision + +### Validate + +`FluentMapper.Validate()` foi exposto como API publica. + +Contrato: + +- valida o estado global atual de `FluentMapper`; +- retorna `void`; +- lanca `FluentMapConfigurationException` quando encontra erros; +- agrega mensagens de mais de uma falha quando o estado atual contem multiplos problemas; +- e idempotente; +- nao altera registry, caches, conventions, entity maps ou type maps do Dapper; +- nao faz I/O, logging ou acesso a banco. + +Motivo para mudar a decisao da Etapa 2: + +- apos as entregas de registro moderno, inheritance, naming policies e constructor mapping, ha mais fontes de configuracao coexistindo; +- o diagnostico agregado agora tem utilidade observavel para tooling, testes de startup e auditoria de configuracao; +- a API permanece pequena e reaproveita as regras ja existentes de validacao. + +### Explain + +`FluentMapper.Explain()` foi exposto como API publica. + +Contrato: + +- retorna `MappingExplanation`; +- funciona antes ou depois de `Initialize`; +- para entidade sem FluentMap registrado, retorna fallback do Dapper para propriedades publicas simples e diagnostico textual auxiliar; +- nao cria mappings; +- nao instala type maps; +- nao invalida caches; +- nao consulta banco; +- produz snapshots read-only. + +Modelo publico: + +```csharp +MappingExplanation +{ + EntityType, + EntityMapType, + ConventionTypes, + Members, + Diagnostics +} + +MemberMappingExplanation +{ + MemberPath, + PropertyInfo, + ColumnName, + Source, + CaseSensitive, + Ignored, + InheritedFrom, + ConventionType, + ConstructorParameters +} + +ConstructorParameterExplanation +{ + Constructor, + Name, + ParameterType +} +``` + +Provenance publica: + +```text +Explicit +Inherited +Convention +NamingPolicy +DapperDefault +``` + +Constructor parameters nao foram modelados como source. Eles sao destino adicional associado a um mapping simples, preservando a distincao entre origem do mapping e destino de materializacao. + +## Delivery + +Arquivos adicionados: + +- `src/Dapper.FluentMap/Diagnostics/MappingSource.cs` +- `src/Dapper.FluentMap/Diagnostics/MappingExplanation.cs` +- `src/Dapper.FluentMap/Diagnostics/MemberMappingExplanation.cs` +- `src/Dapper.FluentMap/Diagnostics/ConstructorParameterExplanation.cs` +- `test/Dapper.FluentMap.Tests/DiagnosticsApiTests.cs` +- `docs/sdd/etapa-3/03-diagnostics-api.md` + +Arquivos alterados: + +- `src/Dapper.FluentMap/FluentMapper.cs` +- `src/Dapper.FluentMap/MappingRegistry.cs` +- `src/Dapper.FluentMap/TypeMaps/FluentConstructorTypeMap.cs` +- `docs/sdd/etapa-3/README.md` +- `docs/sdd/etapa-3/status.md` +- `docs/sdd/etapa-3/decisions.md` + +APIs publicas adicionadas: + +```csharp +FluentMapper.Validate(); +FluentMapper.Explain(); +``` + +Tipos publicos adicionados: + +```text +Dapper.FluentMap.Diagnostics.MappingSource +Dapper.FluentMap.Diagnostics.MappingExplanation +Dapper.FluentMap.Diagnostics.MemberMappingExplanation +Dapper.FluentMap.Diagnostics.ConstructorParameterExplanation +``` + +Exemplo conceitual: + +```text +Id + Column: customer_id + Source: Explicit + +Name + Column: customer_name + Source: NamingPolicy + Constructor parameter: name + +CreatedAt + Column: CreatedAt + Source: DapperDefault +``` + +Implementacao: + +- `Validate()` chama o registry e reexecuta as validacoes existentes sobre maps, bases incluidas, composicao efetiva e conventions; +- erros encontrados em estado global ja corrompido por mutabilidade legada dos dicionarios sao agregados; +- `Explain()` deriva provenance a partir de entity maps, included base maps, conventions e naming policies ja registrados; +- mappings herdados preservam o tipo base que declarou o mapping; +- naming policies sao distinguidas pela convention interna `NamingPolicyConvention`; +- fallback do Dapper e representado por propriedades publicas simples sem mapping efetivo no snapshot; +- constructor parameters sao detectados apenas para mappings simples e nao ignorados; +- nested `MemberPath` aparece no diagnostico, mas nao e tratado como constructor parameter. + +## Compatibility + +Compatibilidade preservada: + +- nenhuma API publica existente foi removida; +- `FluentMapper.Initialize` manteve comportamento; +- `EntityMaps` e `TypeConventions` permanecem publicos por compatibilidade; +- validacoes fail-fast existentes continuam ocorrendo no registro; +- constructor mapping da Entrega 02 nao mudou o contrato de materializacao; +- Dommel nao recebeu alteracao funcional. + +Comportamento publico novo: + +- consumidores podem chamar `FluentMapper.Validate()` para validar o estado global atual; +- consumidores podem chamar `FluentMapper.Explain()` para obter diagnostico estruturado. + +## Tests + +Testes adicionados cobrem: + +- `Validate()` com configuracao valida; +- configuracao invalida; +- multiplos erros agregados; +- chamada repetida; +- ausencia de side effects em cache/registry; +- explicit mapping; +- inherited mapping; +- convention; +- naming policy; +- Dapper default fallback; +- constructor parameter; +- entidade sem mapping; +- paths distintos com mesmo terminal; +- metadata read-only; +- chamada repetida de `Explain()` consistente. + +## Validation + +Validacao localizada executada: + +- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~DiagnosticsApiTests"` + - resultado: sucesso, 11 testes aprovados. +- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~DiagnosticsApiTests|FullyQualifiedName~ConfigurationValidationTests|FullyQualifiedName~MappingCompositionTests|FullyQualifiedName~InheritedMappingTests|FullyQualifiedName~NamingPolicyTests|FullyQualifiedName~ConstructorMappingTests"` + - resultado: sucesso, 68 testes aprovados. + +Validacao final: + +- `dotnet restore` + - resultado: sucesso. +- `dotnet build` + - resultado: sucesso, 0 warnings, 0 erros. +- `dotnet test` + - resultado: sucesso, 128 testes aprovados no core e 7 testes aprovados no Dommel. +- `dotnet build --configuration Release` + - resultado: sucesso, 0 warnings, 0 erros. +- `dotnet test --configuration Release` + - resultado: sucesso, 128 testes aprovados no core e 7 testes aprovados no Dommel. + +`dotnet pack` nao foi executado porque nao houve mudanca de empacotamento, metadados NuGet ou targets. + +## Limitacoes + +- `Explain()` e um snapshot por entidade, nao por consulta SQL especifica. +- Fallback do Dapper e representado de forma conservadora por propriedades publicas simples sem mapping efetivo. +- Ambiguidade de constructor overload continua sendo responsabilidade do Dapper. +- Mensagens agregadas de `Validate()` sao diagnosticas; o contrato estavel e o tipo de excecao e a agregacao, nao texto exato. +- Nao ha cache adicional para diagnosticos. + +## Dividas Fora Do Escopo + +- Roslyn analyzers +- Source generator +- AOT/trimming completo +- Nested object materialization +- Value Objects complexos +- Multiple mapping profiles por tipo +- Query-specific mapping + +## Semantic Commit + +Mensagem planejada: + +```text +feat: add mapping diagnostics API +``` diff --git a/docs/sdd/etapa-3/README.md b/docs/sdd/etapa-3/README.md index 7efaa95..e1c1afc 100644 --- a/docs/sdd/etapa-3/README.md +++ b/docs/sdd/etapa-3/README.md @@ -43,3 +43,35 @@ Entregas: 3. 03 - Validate e Explain O escopo padrao continua sendo o projeto principal `Dapper.FluentMap`. `Dapper.FluentMap.Dommel` nao deve receber alteracao funcional nesta etapa, salvo adaptacao tecnica estritamente necessaria provocada por API compartilhada. + +## Resultado da Etapa 3 + +A Etapa 3 consolidou APIs publicas aditivas para configuracao avancada e diagnostico, preservando o comportamento historico. + +Resumo: + +- novas APIs de registro: `AddMap()`, `AddMapsFromAssembly(...)` e `AddMapsFromAssemblyContaining()`; +- caminho explicito sem scanning por tipo de map, mantendo `AddMap(new CustomerMap())`; +- scanning disponivel como conveniencia, com filtros de namespace, ordenacao deterministica e deteccao de duplicidades; +- constructor mapping integrado a explicit mappings, inherited mappings, conventions e naming policies; +- records posicionais e tipos imutaveis suportados quando os parametros correspondem a propriedades simples resolvidas pelo FluentMap ou pelo fallback do Dapper; +- `Validate()` publico para validar o estado global atual com agregacao de erros; +- `Explain()` publico com modelo estruturado e provenance; +- mudancas publicas foram aditivas e nao removeram APIs historicas; +- diagnostics nao fazem I/O, nao acessam banco, nao invalidam caches e nao registram mappings. + +Limitacoes mantidas fora do escopo: + +- Roslyn analyzers; +- Source generator; +- AOT/trimming completo; +- Nested object materialization; +- Value Objects complexos; +- Multiple mapping profiles por tipo; +- Query-specific mapping. + +Relatorios: + +- `docs/sdd/etapa-3/01-mapping-registration.md` +- `docs/sdd/etapa-3/02-constructor-immutable-mapping.md` +- `docs/sdd/etapa-3/03-diagnostics-api.md` diff --git a/docs/sdd/etapa-3/decisions.md b/docs/sdd/etapa-3/decisions.md index 3fe47f7..3cccdad 100644 --- a/docs/sdd/etapa-3/decisions.md +++ b/docs/sdd/etapa-3/decisions.md @@ -21,3 +21,16 @@ Registre aqui apenas decisoes que afetem entregas posteriores. - A precedencia consolidada tambem vale para parametros de construtor: explicit derivado -> explicit herdado -> convention/naming policy -> Dapper default. - Constructor parameters nao sao `MemberPath`; mappings aninhados nao participam de constructor mapping nem implicam suporte a nested object materialization. - Records posicionais e classes imutaveis passam a funcionar quando seus parametros correspondem a propriedades simples mapeadas ou resolvidas pelo fallback do Dapper. + +## Validate E Explain + +- `Validate()` passa a ser API publica em `FluentMapper`, reaproveitando as validacoes existentes sobre o estado global atual. +- `Validate()` retorna `void`, lanca `FluentMapConfigurationException`, agrega multiplos erros quando encontrados, e deve ser idempotente e sem side effects. +- `Explain()` passa a ser API publica em `FluentMapper` e retorna modelo estruturado, nao apenas string. +- O modelo publico de diagnostico fica no namespace `Dapper.FluentMap.Diagnostics`. +- Provenance publica usa `MappingSource`: `Explicit`, `Inherited`, `Convention`, `NamingPolicy` e `DapperDefault`. +- Constructor parameter e modelado como destino adicional de um mapping simples, nao como origem/provenance. +- `Explain()` deve funcionar antes ou depois de `Initialize`; para entidade sem map/convention registrado, explica fallback do Dapper. +- O diagnostico deve ser snapshot read-only e nao deve expor dictionaries, lists mutaveis do registry ou caches internos. +- `Explain()` nao invalida cache, nao registra mappings, nao instala type maps, nao acessa banco, nao faz I/O e nao adiciona cache proprio. +- A explicacao de fallback e conservadora e nao substitui diagnostico query-specific. diff --git a/docs/sdd/etapa-3/status.md b/docs/sdd/etapa-3/status.md index 7e9cd5f..74ae389 100644 --- a/docs/sdd/etapa-3/status.md +++ b/docs/sdd/etapa-3/status.md @@ -4,4 +4,4 @@ |---|---|---| | 01 - Registro e descoberta de mappings | Concluido | feat: modernize mapping registration | | 02 - Constructor mapping e imutaveis | Concluido | feat: support immutable constructor mappings | -| 03 - Validate e Explain | Pendente | - | +| 03 - Validate e Explain | Concluido | feat: add mapping diagnostics API | diff --git a/src/Dapper.FluentMap/Diagnostics/ConstructorParameterExplanation.cs b/src/Dapper.FluentMap/Diagnostics/ConstructorParameterExplanation.cs new file mode 100644 index 0000000..92f3325 --- /dev/null +++ b/src/Dapper.FluentMap/Diagnostics/ConstructorParameterExplanation.cs @@ -0,0 +1,43 @@ +using System; +using System.Reflection; + +namespace Dapper.FluentMap.Diagnostics +{ + /// + /// Describes a constructor parameter that can receive a mapped column. + /// + public sealed class ConstructorParameterExplanation + { + internal ConstructorParameterExplanation(ConstructorInfo constructor, ParameterInfo parameter) + { + if (constructor == null) + { + throw new ArgumentNullException(nameof(constructor)); + } + + if (parameter == null) + { + throw new ArgumentNullException(nameof(parameter)); + } + + Constructor = constructor; + Name = parameter.Name; + ParameterType = parameter.ParameterType; + } + + /// + /// Gets the constructor that declares the parameter. + /// + public ConstructorInfo Constructor { get; } + + /// + /// Gets the constructor parameter name. + /// + public string Name { get; } + + /// + /// Gets the constructor parameter type. + /// + public Type ParameterType { get; } + } +} diff --git a/src/Dapper.FluentMap/Diagnostics/MappingExplanation.cs b/src/Dapper.FluentMap/Diagnostics/MappingExplanation.cs new file mode 100644 index 0000000..dc778e6 --- /dev/null +++ b/src/Dapper.FluentMap/Diagnostics/MappingExplanation.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Text; + +namespace Dapper.FluentMap.Diagnostics +{ + /// + /// Describes the effective FluentMap diagnostics for an entity type. + /// + public sealed class MappingExplanation + { + internal MappingExplanation( + Type entityType, + Type entityMapType, + IEnumerable conventionTypes, + IEnumerable members, + IEnumerable diagnostics) + { + if (entityType == null) + { + throw new ArgumentNullException(nameof(entityType)); + } + + EntityType = entityType; + EntityMapType = entityMapType; + ConventionTypes = new ReadOnlyCollection( + (conventionTypes ?? Enumerable.Empty()).ToList()); + Members = new ReadOnlyCollection( + (members ?? Enumerable.Empty()).ToList()); + Diagnostics = new ReadOnlyCollection( + (diagnostics ?? Enumerable.Empty()).ToList()); + } + + /// + /// Gets the entity type described by this explanation. + /// + public Type EntityType { get; } + + /// + /// Gets the registered entity map type, when one exists. + /// + public Type EntityMapType { get; } + + /// + /// Gets the registered convention types for the entity. + /// + public IReadOnlyList ConventionTypes { get; } + + /// + /// Gets the effective member mappings. + /// + public IReadOnlyList Members { get; } + + /// + /// Gets additional diagnostics that are not tied to a single member. + /// + public IReadOnlyList Diagnostics { get; } + + /// + public override string ToString() + { + var builder = new StringBuilder(); + builder.Append("Entity: ").Append(EntityType.FullName); + + foreach (var member in Members) + { + builder.AppendLine() + .Append(member.MemberPath) + .Append(" -> ") + .Append(member.ColumnName) + .Append(" (") + .Append(member.Source) + .Append(")"); + } + + return builder.ToString(); + } + } +} diff --git a/src/Dapper.FluentMap/Diagnostics/MappingSource.cs b/src/Dapper.FluentMap/Diagnostics/MappingSource.cs new file mode 100644 index 0000000..d6e78e7 --- /dev/null +++ b/src/Dapper.FluentMap/Diagnostics/MappingSource.cs @@ -0,0 +1,33 @@ +namespace Dapper.FluentMap.Diagnostics +{ + /// + /// Describes the source that provides a mapping in the effective FluentMap configuration. + /// + public enum MappingSource + { + /// + /// The mapping was configured directly on the entity map. + /// + Explicit, + + /// + /// The mapping was included from a registered base entity map. + /// + Inherited, + + /// + /// The mapping was produced by a configured convention. + /// + Convention, + + /// + /// The mapping was produced by a naming policy. + /// + NamingPolicy, + + /// + /// The mapping is left to Dapper's default type map. + /// + DapperDefault + } +} diff --git a/src/Dapper.FluentMap/Diagnostics/MemberMappingExplanation.cs b/src/Dapper.FluentMap/Diagnostics/MemberMappingExplanation.cs new file mode 100644 index 0000000..8856e7d --- /dev/null +++ b/src/Dapper.FluentMap/Diagnostics/MemberMappingExplanation.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Reflection; + +namespace Dapper.FluentMap.Diagnostics +{ + /// + /// Describes the effective mapping metadata for one entity member path. + /// + public sealed class MemberMappingExplanation + { + internal MemberMappingExplanation( + string memberPath, + PropertyInfo propertyInfo, + string columnName, + MappingSource source, + bool caseSensitive, + bool ignored, + Type inheritedFrom, + Type conventionType, + IEnumerable constructorParameters) + { + if (string.IsNullOrEmpty(memberPath)) + { + throw new ArgumentException("Member path cannot be null or empty.", nameof(memberPath)); + } + + if (propertyInfo == null) + { + throw new ArgumentNullException(nameof(propertyInfo)); + } + + MemberPath = memberPath; + PropertyInfo = propertyInfo; + ColumnName = columnName; + Source = source; + CaseSensitive = caseSensitive; + Ignored = ignored; + InheritedFrom = inheritedFrom; + ConventionType = conventionType; + ConstructorParameters = new ReadOnlyCollection( + (constructorParameters ?? Enumerable.Empty()).ToList()); + } + + /// + /// Gets the member path represented by the mapping. + /// + public string MemberPath { get; } + + /// + /// Gets the terminal property represented by the mapping. + /// + public PropertyInfo PropertyInfo { get; } + + /// + /// Gets the configured or default column name. + /// + public string ColumnName { get; } + + /// + /// Gets the source that provides the mapping. + /// + public MappingSource Source { get; } + + /// + /// Gets a value indicating whether the column name comparison is case-sensitive. + /// + public bool CaseSensitive { get; } + + /// + /// Gets a value indicating whether this member is ignored by FluentMap. + /// + public bool Ignored { get; } + + /// + /// Gets the base entity type that declared an inherited mapping, when applicable. + /// + public Type InheritedFrom { get; } + + /// + /// Gets the convention type that produced the mapping, when applicable. + /// + public Type ConventionType { get; } + + /// + /// Gets constructor parameters that can receive this mapped column. + /// + public IReadOnlyList ConstructorParameters { get; } + } +} diff --git a/src/Dapper.FluentMap/FluentMapper.cs b/src/Dapper.FluentMap/FluentMapper.cs index d18b3c9..82390e9 100644 --- a/src/Dapper.FluentMap/FluentMapper.cs +++ b/src/Dapper.FluentMap/FluentMapper.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using Dapper.FluentMap.Configuration; using Dapper.FluentMap.Conventions; +using Dapper.FluentMap.Diagnostics; using Dapper.FluentMap.Mapping; namespace Dapper.FluentMap @@ -37,6 +38,27 @@ public static void Initialize(Action configure) configure(_configuration); } + /// + /// Validates the current Dapper.FluentMap configuration. + /// + /// + /// when one or more configuration errors are found. + /// + public static void Validate() + { + _registry.ValidateConfiguration(); + } + + /// + /// Explains the effective mapping configuration for the specified entity type. + /// + /// The entity type to explain. + /// A structured explanation of configured mappings, conventions and fallback mappings. + public static MappingExplanation Explain() + { + return _registry.Explain(typeof(TEntity)); + } + /// /// Registers a Dapper type map using fluent mapping for the specified . /// diff --git a/src/Dapper.FluentMap/MappingRegistry.cs b/src/Dapper.FluentMap/MappingRegistry.cs index 5061425..fc7e0fd 100644 --- a/src/Dapper.FluentMap/MappingRegistry.cs +++ b/src/Dapper.FluentMap/MappingRegistry.cs @@ -3,7 +3,9 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using System.Text; using Dapper.FluentMap.Conventions; +using Dapper.FluentMap.Diagnostics; using Dapper.FluentMap.Mapping; using Dapper.FluentMap.TypeMaps; @@ -128,6 +130,103 @@ internal IPropertyMap GetConventionPropertyMap(Type type, string columnName) .PropertyMap; } + internal void ValidateConfiguration() + { + var errors = new List(); + + foreach (var entityMap in EntityMaps.OrderBy(e => e.Key.FullName)) + { + try + { + MappingConfigurationValidator.ValidateEntityMap(entityMap.Key, entityMap.Value); + ValidateIncludedBaseMaps(entityMap.Key, entityMap.Value); + MappingConfigurationValidator.ValidateComposedEntityMap( + entityMap.Key, + entityMap.Value, + ComposeExplicitPropertyMaps(entityMap.Key, entityMap.Value)); + } + catch (Exception exception) + { + errors.Add(exception.Message); + } + } + + foreach (var typeConventions in TypeConventions.OrderBy(c => c.Key.FullName)) + { + foreach (var convention in typeConventions.Value) + { + try + { + MappingConfigurationValidator.ValidateConvention(typeConventions.Key, convention); + } + catch (Exception exception) + { + errors.Add(exception.Message); + } + } + } + + if (errors.Count == 0) + { + return; + } + + var message = new StringBuilder() + .Append("Dapper.FluentMap configuration validation found ") + .Append(errors.Count) + .Append(errors.Count == 1 ? " error:" : " errors:"); + + foreach (var error in errors) + { + message.AppendLine().Append("- ").Append(error); + } + + throw new FluentMapConfigurationException(message.ToString()); + } + + internal MappingExplanation Explain(Type type) + { + if (type == null) + { + throw new ArgumentNullException(nameof(type)); + } + + var diagnostics = new List(); + var members = new List(); + var configuredPaths = new List(); + var entityMapType = default(Type); + + if (EntityMaps.TryGetValue(type, out var entityMap)) + { + entityMapType = entityMap.GetType(); + + foreach (var descriptor in ComposeExplicitPropertyMapDescriptors(type, entityMap)) + { + AddMemberExplanation(type, members, configuredPaths, descriptor); + } + } + + var conventionTypes = GetConventionTypes(type).ToList(); + foreach (var descriptor in GetConventionPropertyMapDescriptors(type, configuredPaths)) + { + AddMemberExplanation(type, members, configuredPaths, descriptor); + } + + AddDapperDefaultExplanations(type, members, configuredPaths); + + if (entityMapType == null && conventionTypes.Count == 0) + { + diagnostics.Add("No FluentMap entity map or convention is registered for this entity. Dapper default mapping is used."); + } + + return new MappingExplanation( + type, + entityMapType, + conventionTypes, + members.OrderBy(m => m.MemberPath, StringComparer.Ordinal).ThenBy(m => m.ColumnName, StringComparer.Ordinal), + diagnostics); + } + internal void Reset(params Type[] dapperTypes) { EntityMaps.Clear(); @@ -182,6 +281,16 @@ private IList GetExplicitPropertyMaps(Type type) return new IPropertyMap[0]; } + private IEnumerable GetConventionTypes(Type type) + { + if (!TypeConventions.TryGetValue(type, out var conventions)) + { + return new Type[0]; + } + + return conventions.Select(c => c.GetType()).ToList(); + } + private void ValidateIncludedBaseMaps(Type type, IEntityMap entityMap) { foreach (var baseType in GetIncludedBaseTypes(entityMap)) @@ -202,8 +311,17 @@ private void ValidateIncludedBaseMaps(Type type, IEntityMap entityMap) private IList ComposeExplicitPropertyMaps(Type type, IEntityMap entityMap) { - var propertyMaps = new List(); - AddPropertyMapsWithOverride(propertyMaps, entityMap.PropertyMaps); + return ComposeExplicitPropertyMapDescriptors(type, entityMap) + .Select(d => d.Map) + .ToList(); + } + + private IList ComposeExplicitPropertyMapDescriptors(Type type, IEntityMap entityMap) + { + var propertyMaps = new List(); + AddPropertyMapsWithOverride( + propertyMaps, + entityMap.PropertyMaps.Select(m => MappingDiagnosticDescriptor.Explicit(m))); foreach (var baseType in GetIncludedBaseTypes(entityMap)) { @@ -213,23 +331,26 @@ private IList ComposeExplicitPropertyMaps(Type type, IEntityMap en $"Entity '{type.FullName}' includes base mapping '{baseType.FullName}', but no entity map has been registered for the base type. Register the base map before the derived map."); } - AddPropertyMapsWithOverride(propertyMaps, ComposeExplicitPropertyMaps(baseType, baseMap)); + AddPropertyMapsWithOverride( + propertyMaps, + ComposeExplicitPropertyMapDescriptors(baseType, baseMap) + .Select(d => d.AsInheritedFrom(baseType))); } return propertyMaps; } - private static void AddPropertyMapsWithOverride(IList target, IEnumerable maps) + private static void AddPropertyMapsWithOverride(IList target, IEnumerable maps) { - foreach (var map in maps) + foreach (var descriptor in maps) { - var memberPath = PropertyMapIdentity.GetMemberPath(map); - if (target.Any(existingMap => PropertyMapIdentity.GetMemberPath(existingMap).Equals(memberPath))) + var memberPath = PropertyMapIdentity.GetMemberPath(descriptor.Map); + if (target.Any(existingMap => PropertyMapIdentity.GetMemberPath(existingMap.Map).Equals(memberPath))) { continue; } - target.Add(map); + target.Add(descriptor); } } @@ -244,6 +365,95 @@ private static IList GetIncludedBaseTypes(IEntityMap entityMap) return mapWithIncludedBases.IncludedBaseTypes; } + private IEnumerable GetConventionPropertyMapDescriptors(Type type, IList configuredPaths) + { + if (!TypeConventions.TryGetValue(type, out var conventions)) + { + yield break; + } + + foreach (var convention in conventions) + { + foreach (var map in convention.PropertyMaps) + { + if (!IsMapForEntity(type, map)) + { + continue; + } + + var memberPath = PropertyMapIdentity.GetMemberPath(map); + if (configuredPaths.Any(path => path.Equals(memberPath))) + { + continue; + } + + yield return MappingDiagnosticDescriptor.Convention(map, convention); + } + } + } + + private void AddDapperDefaultExplanations(Type type, IList members, IList configuredPaths) + { + foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.GetIndexParameters().Length == 0)) + { + var memberPath = MemberPath.ForProperty(property); + if (configuredPaths.Any(path => path.Equals(memberPath))) + { + continue; + } + + var constructorParameters = GetConstructorParameters(type, property).ToList(); + members.Add(new MemberMappingExplanation( + memberPath.ToString(), + property, + property.Name, + MappingSource.DapperDefault, + caseSensitive: false, + ignored: false, + inheritedFrom: null, + conventionType: null, + constructorParameters: constructorParameters)); + configuredPaths.Add(memberPath); + } + } + + private void AddMemberExplanation( + Type entityType, + IList members, + IList configuredPaths, + MappingDiagnosticDescriptor descriptor) + { + var memberPath = PropertyMapIdentity.GetMemberPath(descriptor.Map); + var constructorParameters = descriptor.Map.Ignored || memberPath.IsNested + ? new ConstructorParameterExplanation[0] + : GetConstructorParameters(entityType, descriptor.Map.PropertyInfo); + + members.Add(new MemberMappingExplanation( + memberPath.ToString(), + descriptor.Map.PropertyInfo, + descriptor.Map.ColumnName, + descriptor.Source, + descriptor.Map.CaseSensitive, + descriptor.Map.Ignored, + descriptor.InheritedFrom, + descriptor.ConventionType, + constructorParameters)); + configuredPaths.Add(memberPath); + } + + private static IEnumerable GetConstructorParameters(Type entityType, PropertyInfo property) + { + foreach (var constructor in entityType.GetConstructors(BindingFlags.Public | BindingFlags.Instance)) + { + var parameter = FluentConstructorTypeMap.MatchParameter(constructor.GetParameters(), property.Name); + if (parameter != null) + { + yield return new ConstructorParameterExplanation(constructor, parameter); + } + } + } + private IPropertyMap ResolveConventionPropertyMap(Type type, string columnName) { return ResolveConventionPropertyMap(type, columnName, new IPropertyMap[0]); @@ -293,6 +503,15 @@ private static bool IsExplicitlyMapped(IPropertyMap conventionMap, IList PropertyMapIdentity.GetMemberPath(map).Equals(conventionPath)); } + private static bool IsMapForEntity(Type type, IPropertyMap map) + { +#if NETSTANDARD1_3 + return map.PropertyInfo.DeclaringType == type; +#else + return map.PropertyInfo.ReflectedType == type; +#endif + } + private static bool MatchColumnNames(IPropertyMap map, string columnName) { var comparison = map.CaseSensitive @@ -328,5 +547,47 @@ internal MappingCacheEntry(IPropertyMap propertyMap) internal PropertyInfo PropertyInfo { get; } } + + private sealed class MappingDiagnosticDescriptor + { + private MappingDiagnosticDescriptor(IPropertyMap map, MappingSource source, Type inheritedFrom, Type conventionType) + { + Map = map; + Source = source; + InheritedFrom = inheritedFrom; + ConventionType = conventionType; + } + + internal IPropertyMap Map { get; } + + internal MappingSource Source { get; } + + internal Type InheritedFrom { get; } + + internal Type ConventionType { get; } + + internal static MappingDiagnosticDescriptor Explicit(IPropertyMap map) + { + return new MappingDiagnosticDescriptor(map, MappingSource.Explicit, null, null); + } + + internal static MappingDiagnosticDescriptor Convention(IPropertyMap map, Convention convention) + { + var source = convention is NamingPolicyConvention + ? MappingSource.NamingPolicy + : MappingSource.Convention; + + return new MappingDiagnosticDescriptor(map, source, null, convention.GetType()); + } + + internal MappingDiagnosticDescriptor AsInheritedFrom(Type baseType) + { + return new MappingDiagnosticDescriptor( + Map, + MappingSource.Inherited, + InheritedFrom ?? baseType, + ConventionType); + } + } } } diff --git a/src/Dapper.FluentMap/TypeMaps/FluentConstructorTypeMap.cs b/src/Dapper.FluentMap/TypeMaps/FluentConstructorTypeMap.cs index 3a95e5a..1ad644b 100644 --- a/src/Dapper.FluentMap/TypeMaps/FluentConstructorTypeMap.cs +++ b/src/Dapper.FluentMap/TypeMaps/FluentConstructorTypeMap.cs @@ -91,7 +91,7 @@ private IPropertyMap GetSimplePropertyMap(string columnName) return memberPath.IsNested ? null : map; } - private static ParameterInfo MatchParameter(ParameterInfo[] parameters, string memberName) + internal static ParameterInfo MatchParameter(ParameterInfo[] parameters, string memberName) { return parameters.FirstOrDefault(p => string.Equals(p.Name, memberName, StringComparison.Ordinal)) ?? parameters.FirstOrDefault(p => string.Equals(p.Name, memberName, StringComparison.OrdinalIgnoreCase)) diff --git a/test/Dapper.FluentMap.Tests/DiagnosticsApiTests.cs b/test/Dapper.FluentMap.Tests/DiagnosticsApiTests.cs new file mode 100644 index 0000000..05c31f2 --- /dev/null +++ b/test/Dapper.FluentMap.Tests/DiagnosticsApiTests.cs @@ -0,0 +1,433 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Dapper.FluentMap.Conventions; +using Dapper.FluentMap.Diagnostics; +using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.Naming; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class DiagnosticsApiTests + { + [Fact] + public void ValidateShouldSucceedForValidConfigurationAndBeRepeatable() + { + PreTest(typeof(ExplicitDiagnosticEntity)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new ExplicitDiagnosticMap())); + + FluentMapper.Validate(); + FluentMapper.Validate(); + + Assert.True(FluentMapper.EntityMaps.ContainsKey(typeof(ExplicitDiagnosticEntity))); + Assert.Equal(0, FluentMapper.Registry.CacheEntryCount); + } + finally + { + PreTest(typeof(ExplicitDiagnosticEntity)); + } + } + + [Fact] + public void ValidateShouldAggregateErrorsFromCurrentConfiguration() + { + PreTest(typeof(InvalidEmptyColumnEntity), typeof(InvalidForeignMetadataEntity)); + + try + { + FluentMapper.EntityMaps.TryAdd(typeof(InvalidEmptyColumnEntity), new EmptyColumnMap()); + FluentMapper.EntityMaps.TryAdd(typeof(InvalidForeignMetadataEntity), new ForeignMetadataMap()); + + var exception = Assert.Throws(() => FluentMapper.Validate()); + + Assert.Contains("2 errors", exception.Message); + Assert.Contains(typeof(InvalidEmptyColumnEntity).FullName, exception.Message); + Assert.Contains(typeof(InvalidForeignMetadataEntity).FullName, exception.Message); + Assert.Contains("empty column name", exception.Message); + Assert.Contains("not compatible", exception.Message); + } + finally + { + PreTest(typeof(InvalidEmptyColumnEntity), typeof(InvalidForeignMetadataEntity)); + } + } + + [Fact] + public void ExplainShouldDescribeExplicitMappingAndDapperFallback() + { + PreTest(typeof(ExplicitDiagnosticEntity)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new ExplicitDiagnosticMap())); + + var explanation = FluentMapper.Explain(); + + var id = SingleMember(explanation, nameof(ExplicitDiagnosticEntity.Id)); + var name = SingleMember(explanation, nameof(ExplicitDiagnosticEntity.Name)); + + Assert.Equal(typeof(ExplicitDiagnosticEntity), explanation.EntityType); + Assert.Equal(typeof(ExplicitDiagnosticMap), explanation.EntityMapType); + Assert.Equal("explicit_id", id.ColumnName); + Assert.Equal(MappingSource.Explicit, id.Source); + Assert.Equal("Name", name.ColumnName); + Assert.Equal(MappingSource.DapperDefault, name.Source); + } + finally + { + PreTest(typeof(ExplicitDiagnosticEntity)); + } + } + + [Fact] + public void ExplainShouldDescribeInheritedMappings() + { + PreTest(typeof(DiagnosticBaseEntity), typeof(DiagnosticDerivedEntity)); + + try + { + FluentMapper.Initialize(c => + { + c.AddMap(new DiagnosticBaseMap()); + c.AddMap(new DiagnosticDerivedMap()); + }); + + var explanation = FluentMapper.Explain(); + var id = SingleMember(explanation, nameof(DiagnosticBaseEntity.Id)); + + Assert.Equal("base_id", id.ColumnName); + Assert.Equal(MappingSource.Inherited, id.Source); + Assert.Equal(typeof(DiagnosticBaseEntity), id.InheritedFrom); + } + finally + { + PreTest(typeof(DiagnosticBaseEntity), typeof(DiagnosticDerivedEntity)); + } + } + + [Fact] + public void ExplainShouldDescribeConventionMappings() + { + PreTest(typeof(ConventionDiagnosticEntity)); + + try + { + FluentMapper.Initialize(c => c.AddConvention().ForEntity()); + + var explanation = FluentMapper.Explain(); + var name = SingleMember(explanation, nameof(ConventionDiagnosticEntity.Name)); + + Assert.Equal("colName", name.ColumnName); + Assert.Equal(MappingSource.Convention, name.Source); + Assert.Equal(typeof(DiagnosticPrefixConvention), name.ConventionType); + Assert.Contains(typeof(DiagnosticPrefixConvention), explanation.ConventionTypes); + } + finally + { + PreTest(typeof(ConventionDiagnosticEntity)); + } + } + + [Fact] + public void ExplainShouldDescribeNamingPolicyMappings() + { + PreTest(typeof(PolicyDiagnosticEntity)); + + try + { + FluentMapper.Initialize(c => c.UseNamingPolicy(NamingPolicy.SnakeCase, caseSensitive: false).ForEntity()); + + var explanation = FluentMapper.Explain(); + var customerId = SingleMember(explanation, nameof(PolicyDiagnosticEntity.CustomerId)); + + Assert.Equal("customer_id", customerId.ColumnName); + Assert.Equal(MappingSource.NamingPolicy, customerId.Source); + Assert.False(customerId.CaseSensitive); + Assert.NotNull(customerId.ConventionType); + } + finally + { + PreTest(typeof(PolicyDiagnosticEntity)); + } + } + + [Fact] + public void ExplainShouldDescribeConstructorParameterDestinations() + { + PreTest(typeof(ImmutableDiagnosticEntity)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new ImmutableDiagnosticMap())); + + var explanation = FluentMapper.Explain(); + var fullName = SingleMember(explanation, nameof(ImmutableDiagnosticEntity.FullName)); + + Assert.Equal("full_name", fullName.ColumnName); + Assert.Equal(MappingSource.Explicit, fullName.Source); + Assert.Contains(fullName.ConstructorParameters, p => p.Name == "fullName" && p.ParameterType == typeof(string)); + } + finally + { + PreTest(typeof(ImmutableDiagnosticEntity)); + } + } + + [Fact] + public void ExplainShouldDescribeUnconfiguredEntityWithDapperDefaultFallback() + { + PreTest(typeof(UnconfiguredDiagnosticEntity)); + + try + { + var explanation = FluentMapper.Explain(); + var createdAt = SingleMember(explanation, nameof(UnconfiguredDiagnosticEntity.CreatedAt)); + + Assert.Null(explanation.EntityMapType); + Assert.Empty(explanation.ConventionTypes); + Assert.Contains("Dapper default mapping", explanation.Diagnostics.Single()); + Assert.Equal("CreatedAt", createdAt.ColumnName); + Assert.Equal(MappingSource.DapperDefault, createdAt.Source); + } + finally + { + PreTest(typeof(UnconfiguredDiagnosticEntity)); + } + } + + [Fact] + public void ExplainShouldDistinguishSameTerminalMemberPath() + { + PreTest(typeof(NestedDiagnosticsEntity)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new NestedDiagnosticsMap())); + + var explanation = FluentMapper.Explain(); + + Assert.Contains(explanation.Members, m => m.MemberPath == "Rank.Level" && m.ColumnName == "rank_level"); + Assert.Contains(explanation.Members, m => m.MemberPath == "Seniority.Level" && m.ColumnName == "seniority_level"); + } + finally + { + PreTest(typeof(NestedDiagnosticsEntity)); + } + } + + [Fact] + public void ExplainMetadataShouldBeReadOnlySnapshots() + { + PreTest(typeof(ExplicitDiagnosticEntity)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new ExplicitDiagnosticMap())); + + var explanation = FluentMapper.Explain(); + var members = Assert.IsAssignableFrom>(explanation.Members); + var conventionTypes = Assert.IsAssignableFrom>(explanation.ConventionTypes); + + Assert.True(members.IsReadOnly); + Assert.True(conventionTypes.IsReadOnly); + Assert.Throws(() => members.Add(explanation.Members[0])); + Assert.Throws(() => conventionTypes.Add(typeof(DiagnosticPrefixConvention))); + } + finally + { + PreTest(typeof(ExplicitDiagnosticEntity)); + } + } + + [Fact] + public void ExplainRepeatedCallsShouldBeConsistentAndAvoidCacheSideEffects() + { + PreTest(typeof(ExplicitDiagnosticEntity)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new ExplicitDiagnosticMap())); + + var first = FluentMapper.Explain(); + var second = FluentMapper.Explain(); + + Assert.Equal( + first.Members.Select(m => m.MemberPath + ":" + m.ColumnName + ":" + m.Source), + second.Members.Select(m => m.MemberPath + ":" + m.ColumnName + ":" + m.Source)); + Assert.Equal(0, FluentMapper.Registry.CacheEntryCount); + } + finally + { + PreTest(typeof(ExplicitDiagnosticEntity)); + } + } + + private static MemberMappingExplanation SingleMember(MappingExplanation explanation, string memberPath) + { + return explanation.Members.Single(m => m.MemberPath == memberPath); + } + + private static void PreTest(params Type[] types) + { + FluentMapper.Reset(types); + } + + private class ExplicitDiagnosticEntity + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private class ExplicitDiagnosticMap : EntityMap + { + public ExplicitDiagnosticMap() + { + Map(e => e.Id).ToColumn("explicit_id"); + } + } + + private class DiagnosticBaseEntity + { + public int Id { get; set; } + } + + private class DiagnosticDerivedEntity : DiagnosticBaseEntity + { + public string Name { get; set; } + } + + private class DiagnosticBaseMap : EntityMap + { + public DiagnosticBaseMap() + { + Map(e => e.Id).ToColumn("base_id"); + } + } + + private class DiagnosticDerivedMap : EntityMap + { + public DiagnosticDerivedMap() + { + IncludeBase(); + } + } + + private class ConventionDiagnosticEntity + { + public string Name { get; set; } + } + + private class DiagnosticPrefixConvention : Convention + { + public DiagnosticPrefixConvention() + { + Properties() + .Configure(c => c.HasPrefix("col")); + } + } + + private class PolicyDiagnosticEntity + { + public int CustomerId { get; set; } + } + + private class ImmutableDiagnosticEntity + { + public ImmutableDiagnosticEntity(int id, string fullName) + { + Id = id; + FullName = fullName; + } + + public int Id { get; } + + public string FullName { get; } + } + + private class ImmutableDiagnosticMap : EntityMap + { + public ImmutableDiagnosticMap() + { + Map(e => e.Id).ToColumn("person_id"); + Map(e => e.FullName).ToColumn("full_name"); + } + } + + private class UnconfiguredDiagnosticEntity + { + public DateTime CreatedAt { get; set; } + } + + private class NestedDiagnosticsEntity + { + public RankInfo Rank { get; set; } + + public SeniorityInfo Seniority { get; set; } + } + + private class RankInfo + { + public int Level { get; set; } + } + + private class SeniorityInfo + { + public int Level { get; set; } + } + + private class NestedDiagnosticsMap : EntityMap + { + public NestedDiagnosticsMap() + { + Map(e => e.Rank.Level).ToColumn("rank_level"); + Map(e => e.Seniority.Level).ToColumn("seniority_level"); + } + } + + private class InvalidEmptyColumnEntity + { + public int Id { get; set; } + } + + private class InvalidForeignMetadataEntity + { + public int Id { get; set; } + } + + private class ForeignEntity + { + public int Id { get; set; } + } + + private class EmptyColumnMap : IEntityMap + { + public EmptyColumnMap() + { + PropertyMaps = new List + { + new PropertyMap(typeof(InvalidEmptyColumnEntity).GetProperty(nameof(InvalidEmptyColumnEntity.Id)), string.Empty) + }; + } + + public IList PropertyMaps { get; } + } + + private class ForeignMetadataMap : IEntityMap + { + public ForeignMetadataMap() + { + PropertyMaps = new List + { + new PropertyMap(typeof(ForeignEntity).GetProperty(nameof(ForeignEntity.Id)), "foreign_id") + }; + } + + public IList PropertyMaps { get; } + } + } +} From 9075f61a6fdd488b92cb0d8301bdf69a62e7b91c Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sun, 26 Jul 2026 11:03:25 -0300 Subject: [PATCH 08/20] feat: add FluentMap Roslyn analyzers --- Dapper.FluentMap.sln | 14 + docs/sdd/etapa-4/01-roslyn-analyzers.md | 240 +++++++ docs/sdd/etapa-4/README.md | 56 ++ docs/sdd/etapa-4/decisions.md | 21 + docs/sdd/etapa-4/status.md | 5 + .../AnalyzerReleases.Shipped.md | 6 + .../AnalyzerReleases.Unshipped.md | 9 + .../Dapper.FluentMap.Analyzers.csproj | 26 + .../FluentMapConfigurationAnalyzer.cs | 666 ++++++++++++++++++ src/Dapper.FluentMap.Analyzers/README.md | 5 + .../Dapper.FluentMap.Analyzers.Tests.csproj | 17 + .../FluentMapConfigurationAnalyzerTests.cs | 337 +++++++++ 12 files changed, 1402 insertions(+) create mode 100644 docs/sdd/etapa-4/01-roslyn-analyzers.md create mode 100644 docs/sdd/etapa-4/README.md create mode 100644 docs/sdd/etapa-4/decisions.md create mode 100644 docs/sdd/etapa-4/status.md create mode 100644 src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Shipped.md create mode 100644 src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Unshipped.md create mode 100644 src/Dapper.FluentMap.Analyzers/Dapper.FluentMap.Analyzers.csproj create mode 100644 src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs create mode 100644 src/Dapper.FluentMap.Analyzers/README.md create mode 100644 test/Dapper.FluentMap.Analyzers.Tests/Dapper.FluentMap.Analyzers.Tests.csproj create mode 100644 test/Dapper.FluentMap.Analyzers.Tests/FluentMapConfigurationAnalyzerTests.cs diff --git a/Dapper.FluentMap.sln b/Dapper.FluentMap.sln index 635b223..75312d2 100644 --- a/Dapper.FluentMap.sln +++ b/Dapper.FluentMap.sln @@ -19,6 +19,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dapper.FluentMap.Dommel", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapper.FluentMap.Dommel.Tests", "test\Dapper.FluentMap.Dommel.Tests\Dapper.FluentMap.Dommel.Tests.csproj", "{DFB62D87-9A74-40DF-A930-8F61A53E0F1B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapper.FluentMap.Analyzers", "src\Dapper.FluentMap.Analyzers\Dapper.FluentMap.Analyzers.csproj", "{424B90AD-406E-4CC1-B0F4-917F47A06E4D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapper.FluentMap.Analyzers.Tests", "test\Dapper.FluentMap.Analyzers.Tests\Dapper.FluentMap.Analyzers.Tests.csproj", "{F5059D11-D45B-4793-B6E0-7758F57AC0E1}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -41,6 +45,14 @@ Global {DFB62D87-9A74-40DF-A930-8F61A53E0F1B}.Debug|Any CPU.Build.0 = Debug|Any CPU {DFB62D87-9A74-40DF-A930-8F61A53E0F1B}.Release|Any CPU.ActiveCfg = Release|Any CPU {DFB62D87-9A74-40DF-A930-8F61A53E0F1B}.Release|Any CPU.Build.0 = Release|Any CPU + {424B90AD-406E-4CC1-B0F4-917F47A06E4D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {424B90AD-406E-4CC1-B0F4-917F47A06E4D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {424B90AD-406E-4CC1-B0F4-917F47A06E4D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {424B90AD-406E-4CC1-B0F4-917F47A06E4D}.Release|Any CPU.Build.0 = Release|Any CPU + {F5059D11-D45B-4793-B6E0-7758F57AC0E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F5059D11-D45B-4793-B6E0-7758F57AC0E1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F5059D11-D45B-4793-B6E0-7758F57AC0E1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F5059D11-D45B-4793-B6E0-7758F57AC0E1}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -50,6 +62,8 @@ Global {8901F2FD-F98B-484B-A20A-7844A39C7458} = {742442F2-CAE7-4DC8-BD73-8C54C0005A53} {E60B79F6-FE71-44E0-BE88-BFA269378EDB} = {580E3446-6579-4414-9875-970849E635E5} {DFB62D87-9A74-40DF-A930-8F61A53E0F1B} = {742442F2-CAE7-4DC8-BD73-8C54C0005A53} + {424B90AD-406E-4CC1-B0F4-917F47A06E4D} = {580E3446-6579-4414-9875-970849E635E5} + {F5059D11-D45B-4793-B6E0-7758F57AC0E1} = {742442F2-CAE7-4DC8-BD73-8C54C0005A53} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {10834736-59FD-47FF-9344-096247DC48CD} diff --git a/docs/sdd/etapa-4/01-roslyn-analyzers.md b/docs/sdd/etapa-4/01-roslyn-analyzers.md new file mode 100644 index 0000000..103f9a4 --- /dev/null +++ b/docs/sdd/etapa-4/01-roslyn-analyzers.md @@ -0,0 +1,240 @@ +# 01 - Roslyn Analyzers + +## Specification + +Criar analyzers Roslyn para antecipar problemas de configuracao do `Dapper.FluentMap` em compile-time, sem duplicar toda a logica runtime. + +Objetivos tratados nesta primeira versao: + +- expression invalida passada para `Map(...)`; +- uso de membro nao suportado em `Map(...)`; +- mapping duplicado evidente para o mesmo `MemberPath`; +- conflito inequivoco de coluna por `ToColumn(...)` literal; +- `IncludeBase()` com tipo que nao e base class real da entidade; +- `AddMap()` com tipo que nao implementa exatamente uma interface fechada `IEntityMap` para entidade class. + +Fora do objetivo: + +- executar construtores de maps; +- instanciar mappings; +- simular assembly scanning; +- substituir `Validate()` ou as validacoes fail-fast; +- diagnosticar preferencias de estilo; +- criar Code Fix Provider; +- alterar comportamento runtime do core; +- alterar Dommel funcionalmente. + +## Discovery + +Arquivos e decisoes analisados: + +- `AGENTS.md` +- `.agents/skills/msbuild-modernization/SKILL.md` +- `.agents/skills/msbuild-antipatterns/SKILL.md` +- `.agents/skills/run-tests/SKILL.md` +- `docs/sdd/etapa-1/README.md` +- `docs/sdd/etapa-1/decisions.md` +- `docs/sdd/etapa-2/README.md` +- `docs/sdd/etapa-2/decisions.md` +- `docs/sdd/etapa-2/01-member-path.md` +- `docs/sdd/etapa-2/02-configuration-validation.md` +- `docs/sdd/etapa-3/README.md` +- `docs/sdd/etapa-3/decisions.md` +- `docs/sdd/etapa-3/01-mapping-registration.md` +- `docs/sdd/etapa-3/02-constructor-immutable-mapping.md` +- `docs/sdd/etapa-3/03-diagnostics-api.md` +- `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs` +- `src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs` +- `src/Dapper.FluentMap/Mapping/EntityMap.cs` +- `src/Dapper.FluentMap/Mapping/PropertyMap.cs` +- `src/Dapper.FluentMap/Mapping/MemberPath.cs` +- `src/Dapper.FluentMap/Mapping/PropertyMapIdentity.cs` +- `src/Dapper.FluentMap/MappingConfigurationValidator.cs` +- `src/Dapper.FluentMap/FluentMapper.cs` +- projetos `.csproj`, `Dapper.FluentMap.sln` e `NuGet.Config`. + +Catalogo de erros e classificacao: + +| Condicao | Classificacao | +|---|---| +| `Map(...)` com lambda que nao resolve para property path | Pode ser detectado estaticamente quando a lambda e literal | +| `Map(...)` usando metodo, campo, indexer ou expressao composta | Pode ser detectado estaticamente quando a lambda e literal | +| Mesmo `MemberPath` mapeado duas vezes no mesmo construtor de map | Pode ser detectado parcialmente | +| Dois paths distintos com mesmo terminal, como `Rank.Level` e `Seniority.Level` | Configuracao valida; nao diagnosticar | +| Dois mappings explicitos para a mesma coluna literal no mesmo construtor | Pode ser detectado parcialmente | +| Conflito de coluna calculada dinamicamente | Somente runtime | +| Conflito entre explicit mapping e convention | Somente runtime/predecencia existente | +| Convention ambigua ou sem `Configure(...)` | Somente runtime, pois depende de predicates e transformers | +| `ToColumn(null)` ou `ToColumn("")` literal | Detectavel, mas nao implementado nesta primeira versao para manter conjunto pequeno | +| `IncludeBase()` com interface, mesmo tipo ou tipo nao relacionado | Pode ser detectado estaticamente | +| `IncludeBase()` com base map nao registrado | Somente runtime, pois depende da ordem real de registro | +| `AddMap()` com map nao generico ou multiplas entidades | Pode ser detectado estaticamente | +| `AddMap()` com map abstrato ou sem construtor publico sem parametros | Ja e coberto pelo compilador via constraint `new()` quando aplicavel | +| `AddMapsFromAssembly(...)` com tipos descobertos invalidos | Somente runtime/reflection | +| Constructor mapping impossivel por overloads/parametros opcionais | Somente Dapper/runtime | +| Nested object materialization | Fora do contrato; nao diagnosticar apenas por `MemberPath` aninhado | + +Infraestrutura encontrada: + +- nao havia projeto de analyzer; +- nao havia Central Package Management; +- versoes de pacotes sao declaradas em cada `.csproj`; +- testes usam `net10.0`, `Microsoft.NET.Test.Sdk` e `xunit.v3`; +- solution possui folders `src` e `test`; +- `NuGet.Config` usa `nuget.org`. + +Pacotes escolhidos: + +- `Microsoft.CodeAnalysis.CSharp` `5.6.0` para o analyzer e a harness de testes; +- `Microsoft.CodeAnalysis.Analyzers` `5.6.0` no projeto de analyzer, com `PrivateAssets="all"`; +- os pacotes de teste seguem as versoes ja usadas nos testes existentes. + +Impacto futuro sobre Source Generator: + +- a leitura estatica de lambdas e cadeias `Map(...).ToColumn(...)` pode ser reaproveitada; +- o source generator nao deve assumir que todo mapping valido esta disponivel estaticamente; +- chamadas auxiliares, configuracao dinamica e assembly scanning continuam exigindo fallback runtime. + +## Decision + +Diagnostics iniciais: + +| ID | Severidade | Situacao | Detectavel estaticamente? | +|---|---|---|---| +| DFM001 | Error | `Map(...)` recebe lambda literal que nao resolve para property path de propriedades suportadas | Sim | +| DFM002 | Error | mesmo `MemberPath` aparece em duas chamadas diretas de `Map(...)` no mesmo construtor de `EntityMap` | Parcialmente, somente padrao direto | +| DFM003 | Error | dois `MemberPath`s distintos no mesmo construtor resolvem a mesma coluna literal por `ToColumn(...)` | Parcialmente, somente constantes | +| DFM004 | Error | `IncludeBase()` usa tipo que nao e base class real da entidade do map | Sim | +| DFM005 | Error | `AddMap()` usa tipo que nao implementa exatamente um `IEntityMap` fechado para entidade class | Sim | + +Severidade: + +- todos sao `Error` porque representam configuracoes que o runtime ja rejeita ou que o compilador consegue provar como invalidas; +- nenhum diagnostic de estilo foi criado. + +Code fixes: + +- nenhum Code Fix Provider foi entregue; +- corrigir expression, escolher coluna, escolher base type ou substituir map type pode alterar intencao de dominio. + +## Delivery + +Arquivos adicionados: + +- `src/Dapper.FluentMap.Analyzers/Dapper.FluentMap.Analyzers.csproj` +- `src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs` +- `src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Shipped.md` +- `src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Unshipped.md` +- `test/Dapper.FluentMap.Analyzers.Tests/Dapper.FluentMap.Analyzers.Tests.csproj` +- `test/Dapper.FluentMap.Analyzers.Tests/FluentMapConfigurationAnalyzerTests.cs` +- `docs/sdd/etapa-4/README.md` +- `docs/sdd/etapa-4/status.md` +- `docs/sdd/etapa-4/decisions.md` +- `docs/sdd/etapa-4/01-roslyn-analyzers.md` + +Arquivos alterados: + +- `Dapper.FluentMap.sln` + +Estrutura: + +```text +src/Dapper.FluentMap.Analyzers/ +test/Dapper.FluentMap.Analyzers.Tests/ +``` + +Implementacao: + +- analyzer baseado em `DiagnosticAnalyzer(LanguageNames.CSharp)`; +- usa `SyntaxNodeAction` para invocacoes e `CompilationEndAction` apenas para agregacoes locais de duplicidade/conflito; +- compara symbols para identificar APIs do FluentMap; +- interpreta lambdas literais de `Map(...)` sem executar codigo; +- aceita caminhos simples, paths aninhados e casts explicitos em torno da expressao; +- considera duplicidade/conflito apenas em statements diretos do construtor, evitando fluxo arbitrario; +- conflito de coluna exige coluna conhecida estaticamente e respeita `caseSensitive` literal; +- chamadas com coluna dinamica, bool dinamico ou `Ignore()` ficam sem diagnostic de coluna; +- `AddMap()` valida o contrato de exatamente um `IEntityMap` fechado e entidade class. + +Packaging: + +- o projeto analyzer e `netstandard2.0`; +- `IncludeBuildOutput=false`; +- o assembly do analyzer e empacotado em `analyzers/dotnet/cs`; +- `SuppressDependenciesWhenPacking=true`, evitando dependencia runtime para Roslyn no `.nuspec`; +- `PackageLicenseExpression=MIT`; +- readme minimo incluido no pacote; +- o core nao referencia o analyzer; +- o core nao recebeu dependencias Roslyn. + +## Validation + +Validacao localizada executada durante a entrega: + +- `dotnet restore .\Dapper.FluentMap.sln` + - resultado: sucesso. +- `dotnet build .\test\Dapper.FluentMap.Analyzers.Tests\Dapper.FluentMap.Analyzers.Tests.csproj --no-restore` + - resultado inicial: sucesso com warnings RS1036, RS1037, RS2008 e xUnit2031. +- Ajustes: + - `EnforceExtendedAnalyzerRules=true`; + - release tracking em `AnalyzerReleases.Shipped.md` e `AnalyzerReleases.Unshipped.md`; + - `WellKnownDiagnosticTags.CompilationEnd` em `DFM002` e `DFM003`; + - uso do overload de `Assert.Single` com predicate. +- `dotnet build .\test\Dapper.FluentMap.Analyzers.Tests\Dapper.FluentMap.Analyzers.Tests.csproj --no-restore` + - resultado: sucesso, 0 warnings, 0 erros. +- `dotnet test .\test\Dapper.FluentMap.Analyzers.Tests\Dapper.FluentMap.Analyzers.Tests.csproj --no-build` + - resultado: sucesso, 7 testes aprovados. + +Testes do analyzer cobrem: + +- positivo, mensagem, severidade e localizacao para `DFM001`; +- positivo, mensagem, severidade e localizacao para `DFM002`; +- positivo, mensagem, severidade e localizacao para `DFM003`; +- positivo, mensagem, severidade e localizacao para `DFM004`; +- positivo, mensagem, severidade e localizacao para `DFM005`; +- mapping valido sem diagnostics; +- expression valida; +- `MemberPath` aninhado valido; +- inheritance valido; +- registration valido; +- record/constructor mapping valido; +- ausencia de falso positivo em colunas com casing diferente quando ambas sao case-sensitive. + +Validacao final completa: + +- `dotnet restore` + - resultado: sucesso. +- `dotnet build` + - resultado: sucesso, 0 warnings, 0 erros. +- `dotnet test` + - resultado: sucesso, 128 testes aprovados no core, 7 no Dommel e 7 no analyzer. +- `dotnet build --configuration Release` + - resultado: sucesso, 0 warnings, 0 erros. +- `dotnet test --configuration Release` + - resultado: sucesso, 128 testes aprovados no core, 7 no Dommel e 7 no analyzer. +- `dotnet test .\test\Dapper.FluentMap.Analyzers.Tests\Dapper.FluentMap.Analyzers.Tests.csproj --no-build` + - resultado: sucesso, 7 testes aprovados. +- `dotnet pack .\src\Dapper.FluentMap.Analyzers\Dapper.FluentMap.Analyzers.csproj --configuration Release --no-build --output .\artifacts\packages` + - resultado: sucesso, pacote `Dapper.FluentMap.Analyzers.2.0.0.nupkg` criado sem warnings. +- inspecao do `.nupkg` + - resultado: contem `README.md` e `analyzers/dotnet/cs/Dapper.FluentMap.Analyzers.dll`; nao contem `lib/`. +- `dotnet list .\src\Dapper.FluentMap\Dapper.FluentMap.csproj package --include-transitive` + - resultado: o projeto principal continua com `Dapper` como unica dependencia direta; nenhuma dependencia Roslyn foi adicionada ao core. + +Confirmacoes: + +- core nao referencia Roslyn; +- pacote principal nao recebe dependencias Roslyn; +- analyzer nao muda runtime; +- diagnostics aparecem apenas nos cenarios cobertos; +- suite anterior continua verde. + +## Limitacoes + +- Nao analisa chamadas `Map(...)` indiretas por helper method. +- Nao analisa duplicidade em fluxos condicionais, loops ou chamadas fora de statements diretos do construtor. +- Nao executa constructor mapping nem simula o Dapper. +- Nao diagnostica `AddMapsFromAssembly(...)`, pois discovery depende de reflection e ambiente runtime. +- Nao diagnostica base map ausente em `IncludeBase()`, pois depende de registro real. +- Nao diagnostica transformers de naming policy ou convention. +- Nao diagnostica materializacao aninhada. +- Nao ha Code Fix Provider. diff --git a/docs/sdd/etapa-4/README.md b/docs/sdd/etapa-4/README.md new file mode 100644 index 0000000..cb90700 --- /dev/null +++ b/docs/sdd/etapa-4/README.md @@ -0,0 +1,56 @@ +# Etapa 4 + +## Objetivo + +Evoluir o `Dapper.FluentMap` com tooling de build-time e compatibilidade de publicacao, preservando o contrato runtime consolidado nas Etapas 1, 2 e 3. + +## Dependencia Das Etapas 1, 2 E 3 + +Esta etapa depende das decisoes anteriores sobre `MemberPath`, validacao runtime, heranca de mappings, naming policies, registro moderno, constructor mapping, `Validate()`, `Explain()` e provenance de mappings. + +Antes de iniciar qualquer entrega desta etapa, leia: + +- `docs/sdd/etapa-1/README.md` +- `docs/sdd/etapa-1/decisions.md` +- `docs/sdd/etapa-2/README.md` +- `docs/sdd/etapa-2/decisions.md` +- os relatorios relevantes da Etapa 2 +- `docs/sdd/etapa-3/README.md` +- `docs/sdd/etapa-3/decisions.md` +- os relatorios relevantes da Etapa 3 +- o relatorio anterior desta pasta, quando existir + +## Escopo + +Entregas: + +1. 01 - Roslyn Analyzers +2. 02 - Trimming e Native AOT +3. 03 - Source Generator + +## Compatibilidade + +- O pacote principal `Dapper.FluentMap` deve continuar em `netstandard2.0`. +- O runtime do core nao deve ganhar dependencias Roslyn. +- APIs publicas existentes devem ser preservadas. +- `Dapper.FluentMap.Dommel` nao deve receber alteracao funcional nesta etapa salvo necessidade comprovada. +- Diagnostics e IDs publicados passam a ser contrato de tooling e nao devem ser renumerados ou reutilizados. + +## Runtime Continua Autoridade + +Analyzers nao substituem `Validate()` nem as validacoes fail-fast do runtime. + +Motivos: + +- o analyzer pode nao estar instalado; +- configuracao pode ser dinamica; +- assembly scanning depende de reflection; +- construtores de maps podem executar logica arbitraria; +- consumidores podem suprimir diagnostics; +- o runtime possui informacoes indisponiveis no compilador. + +Regra principal: + +```text +Se nao for possivel provar estaticamente, nao reporte como erro. +``` diff --git a/docs/sdd/etapa-4/decisions.md b/docs/sdd/etapa-4/decisions.md new file mode 100644 index 0000000..54edbf5 --- /dev/null +++ b/docs/sdd/etapa-4/decisions.md @@ -0,0 +1,21 @@ +# Decisoes Da Etapa 4 + +Registre aqui apenas decisoes que afetem entregas posteriores. + +## Roslyn Analyzers + +- Analyzers sao entregues em projeto e pacote isolado `Dapper.FluentMap.Analyzers`, sem referencia do core para Roslyn. +- A primeira versao dos diagnostics usa o prefixo `DFM` e IDs `DFM001` a `DFM005`. +- Todos os diagnostics iniciais sao `Error`, mas somente para situacoes provadas estaticamente com alto grau de confianca. +- Duplicidade de `MemberPath` e conflito de coluna sao analisados apenas para chamadas diretas de `Map(...).ToColumn(...)` em statements diretos do construtor do `EntityMap`. +- O analyzer nao executa codigo de usuario, nao instancia maps, nao faz reflection runtime e nao acessa banco. +- Regras dependentes de fluxo de execucao, chamadas auxiliares, scanning de assembly, construtores de maps, ordem real de registro ou estado global permanecem sob autoridade de `Validate()` e das validacoes runtime. +- Nao foi criado Code Fix Provider porque nenhuma correcao inicial e inequivoca sem risco de alterar semantica. + +## Trimming E Native AOT + +- A Entrega 02 deve considerar que o analyzer ja identifica alguns usos estaticamente invalidos de `AddMap()`, mas isso nao remove a divida de reflection documentada na Etapa 3. + +## Source Generator + +- A Entrega 03 pode reutilizar a leitura estatica de `Map(...)`, `ToColumn(...)`, `IncludeBase(...)` e `AddMap()`, mas nao deve depender de diagnostics como unica fonte de verdade. diff --git a/docs/sdd/etapa-4/status.md b/docs/sdd/etapa-4/status.md new file mode 100644 index 0000000..c865632 --- /dev/null +++ b/docs/sdd/etapa-4/status.md @@ -0,0 +1,5 @@ +| Entrega | Status | Commit | +|---|---|---| +| 01 - Roslyn Analyzers | Concluido | - | +| 02 - Trimming e Native AOT | Pendente | - | +| 03 - Source Generator | Pendente | - | diff --git a/src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Shipped.md b/src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..7a70022 --- /dev/null +++ b/src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Shipped.md @@ -0,0 +1,6 @@ +## Release 2.0.0 + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|-------------------- diff --git a/src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Unshipped.md b/src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..244a9d5 --- /dev/null +++ b/src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Unshipped.md @@ -0,0 +1,9 @@ +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|-------------------- +DFM001 | Dapper.FluentMap.Configuration | Error | Map expression must resolve to a property path. +DFM002 | Dapper.FluentMap.Configuration | Error | Property path is mapped more than once. +DFM003 | Dapper.FluentMap.Configuration | Error | Column is mapped by more than one property path. +DFM004 | Dapper.FluentMap.Configuration | Error | Included mapping type must be a base class. +DFM005 | Dapper.FluentMap.Configuration | Error | Generic map registration type is invalid. diff --git a/src/Dapper.FluentMap.Analyzers/Dapper.FluentMap.Analyzers.csproj b/src/Dapper.FluentMap.Analyzers/Dapper.FluentMap.Analyzers.csproj new file mode 100644 index 0000000..127a392 --- /dev/null +++ b/src/Dapper.FluentMap.Analyzers/Dapper.FluentMap.Analyzers.csproj @@ -0,0 +1,26 @@ + + + Roslyn analyzers for Dapper.FluentMap configuration. + 2.0.0 + Henk Mollema + netstandard2.0 + true + false + Dapper.FluentMap.Analyzers + c#;dapper;mapping;fluentmap;roslyn;analyzers + https://github.com/henkmollema/Dapper-FluentMap + MIT + README.md + true + + + + + + + + + + + + diff --git a/src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs b/src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs new file mode 100644 index 0000000..cd46ddd --- /dev/null +++ b/src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs @@ -0,0 +1,666 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Dapper.FluentMap.Analyzers +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public sealed class FluentMapConfigurationAnalyzer : DiagnosticAnalyzer + { + public const string InvalidMapExpressionDiagnosticId = "DFM001"; + public const string DuplicateMemberPathDiagnosticId = "DFM002"; + public const string DuplicateColumnDiagnosticId = "DFM003"; + public const string InvalidIncludeBaseDiagnosticId = "DFM004"; + public const string InvalidGenericMapRegistrationDiagnosticId = "DFM005"; + + private const string Category = "Dapper.FluentMap.Configuration"; + private const string MappingNamespace = "Dapper.FluentMap.Mapping"; + private const string ConfigurationNamespace = "Dapper.FluentMap.Configuration"; + + private static readonly DiagnosticDescriptor InvalidMapExpressionRule = new DiagnosticDescriptor( + InvalidMapExpressionDiagnosticId, + "Map expression must resolve to a property path", + "Map expression '{0}' is invalid: {1}", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Dapper.FluentMap Map expressions must resolve to a property path rooted in the entity parameter."); + + private static readonly DiagnosticDescriptor DuplicateMemberPathRule = new DiagnosticDescriptor( + DuplicateMemberPathDiagnosticId, + "Property path is mapped more than once", + "Property path '{0}' is mapped more than once in this entity map constructor", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Mapping the same property path more than once in the same entity map constructor is an invalid FluentMap configuration.", + customTags: WellKnownDiagnosticTags.CompilationEnd); + + private static readonly DiagnosticDescriptor DuplicateColumnRule = new DiagnosticDescriptor( + DuplicateColumnDiagnosticId, + "Column is mapped by more than one property path", + "Column '{0}' is mapped by more than one property path in this entity map constructor: '{1}' and '{2}'", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Two explicit FluentMap mappings in the same entity map constructor must not resolve the same column when that conflict is statically known.", + customTags: WellKnownDiagnosticTags.CompilationEnd); + + private static readonly DiagnosticDescriptor InvalidIncludeBaseRule = new DiagnosticDescriptor( + InvalidIncludeBaseDiagnosticId, + "Included mapping type must be a base class", + "Type '{0}' cannot be included as a base mapping for entity '{1}'", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "IncludeBase() can only include a real base class of the entity mapped by the current EntityMap."); + + private static readonly DiagnosticDescriptor InvalidGenericMapRegistrationRule = new DiagnosticDescriptor( + InvalidGenericMapRegistrationDiagnosticId, + "Generic map registration type is invalid", + "Entity map type '{0}' must implement exactly one closed IEntityMap interface targeting a class type", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "AddMap() can only register map types that implement exactly one closed IEntityMap interface whose entity type is a class."); + + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create( + InvalidMapExpressionRule, + DuplicateMemberPathRule, + DuplicateColumnRule, + InvalidIncludeBaseRule, + InvalidGenericMapRegistrationRule); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterCompilationStartAction(startContext => + { + var constructorMapInvocations = new ConcurrentBag(); + + startContext.RegisterSyntaxNodeAction( + nodeContext => AnalyzeInvocation(nodeContext, constructorMapInvocations), + SyntaxKind.InvocationExpression); + + startContext.RegisterCompilationEndAction( + endContext => AnalyzeConstructorMapInvocations(endContext, constructorMapInvocations)); + }); + } + + private static void AnalyzeInvocation( + SyntaxNodeAnalysisContext context, + ConcurrentBag constructorMapInvocations) + { + var invocation = (InvocationExpressionSyntax)context.Node; + var method = context.SemanticModel.GetSymbolInfo(invocation, context.CancellationToken).Symbol as IMethodSymbol; + + if (method == null) + { + return; + } + + if (IsMapInvocation(method)) + { + AnalyzeMapInvocation(context, invocation, constructorMapInvocations); + return; + } + + if (IsIncludeBaseInvocation(method)) + { + AnalyzeIncludeBaseInvocation(context, invocation, method); + return; + } + + if (IsGenericAddMapInvocation(method)) + { + AnalyzeGenericAddMapInvocation(context, invocation, method); + } + } + + private static void AnalyzeMapInvocation( + SyntaxNodeAnalysisContext context, + InvocationExpressionSyntax invocation, + ConcurrentBag constructorMapInvocations) + { + if (invocation.ArgumentList.Arguments.Count != 1) + { + return; + } + + var argument = invocation.ArgumentList.Arguments[0].Expression; + if (!TryGetLambda(argument, out var lambda)) + { + return; + } + + if (!TryCreateMemberPath(lambda.Body, context.SemanticModel, context.CancellationToken, out var memberPath, out var reason)) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidMapExpressionRule, + lambda.Body.GetLocation(), + lambda.Body.ToString(), + reason)); + return; + } + + if (!TryCreateDirectConstructorMapInvocation( + invocation, + context.SemanticModel, + memberPath, + context.CancellationToken, + out var mapInvocation)) + { + return; + } + + constructorMapInvocations.Add(mapInvocation); + } + + private static void AnalyzeIncludeBaseInvocation( + SyntaxNodeAnalysisContext context, + InvocationExpressionSyntax invocation, + IMethodSymbol method) + { + if (method.TypeArguments.Length != 1) + { + return; + } + + var containingType = context.ContainingSymbol?.ContainingType; + if (containingType == null) + { + return; + } + + var entityType = FindEntityType(containingType); + var baseType = method.TypeArguments[0] as INamedTypeSymbol; + if (entityType == null || baseType == null) + { + return; + } + + if (baseType.TypeKind == TypeKind.Class && + !SymbolEqualityComparer.Default.Equals(baseType, entityType) && + IsAssignableTo(entityType, baseType)) + { + return; + } + + context.ReportDiagnostic(Diagnostic.Create( + InvalidIncludeBaseRule, + invocation.GetLocation(), + FormatSymbol(baseType), + FormatSymbol(entityType))); + } + + private static void AnalyzeGenericAddMapInvocation( + SyntaxNodeAnalysisContext context, + InvocationExpressionSyntax invocation, + IMethodSymbol method) + { + if (method.TypeArguments.Length != 1) + { + return; + } + + var mapType = method.TypeArguments[0] as INamedTypeSymbol; + if (mapType == null) + { + return; + } + + var entityMapInterfaces = mapType.AllInterfaces + .Where(type => IsType(type.OriginalDefinition, MappingNamespace, "IEntityMap`1")) + .ToList(); + + if (entityMapInterfaces.Count == 1 && + entityMapInterfaces[0].TypeArguments[0].TypeKind == TypeKind.Class) + { + return; + } + + context.ReportDiagnostic(Diagnostic.Create( + InvalidGenericMapRegistrationRule, + invocation.GetLocation(), + FormatSymbol(mapType))); + } + + private static void AnalyzeConstructorMapInvocations( + CompilationAnalysisContext context, + ConcurrentBag constructorMapInvocations) + { + var groups = constructorMapInvocations + .GroupBy(invocation => invocation.Constructor, SymbolEqualityComparer.Default); + + foreach (var group in groups) + { + var invocations = group + .OrderBy(invocation => invocation.InvocationLocation.SourceSpan.Start) + .ToList(); + + ReportDuplicateMemberPaths(context, invocations); + ReportDuplicateColumns(context, invocations); + } + } + + private static void ReportDuplicateMemberPaths( + CompilationAnalysisContext context, + IList invocations) + { + var seen = new Dictionary(StringComparer.Ordinal); + + foreach (var invocation in invocations) + { + if (seen.ContainsKey(invocation.MemberPath.Key)) + { + context.ReportDiagnostic(Diagnostic.Create( + DuplicateMemberPathRule, + invocation.InvocationLocation, + invocation.MemberPath.Display)); + continue; + } + + seen.Add(invocation.MemberPath.Key, invocation); + } + } + + private static void ReportDuplicateColumns( + CompilationAnalysisContext context, + IList invocations) + { + for (var i = 0; i < invocations.Count; i++) + { + var left = invocations[i]; + if (!left.ColumnKnown || left.Ignored) + { + continue; + } + + for (var j = i + 1; j < invocations.Count; j++) + { + var right = invocations[j]; + if (!right.ColumnKnown || + right.Ignored || + left.MemberPath.Key == right.MemberPath.Key || + !ColumnNamesOverlap(left, right)) + { + continue; + } + + context.ReportDiagnostic(Diagnostic.Create( + DuplicateColumnRule, + right.ColumnLocation, + right.ColumnName, + left.MemberPath.Display, + right.MemberPath.Display)); + } + } + } + + private static bool ColumnNamesOverlap(MapInvocation left, MapInvocation right) + { + if (string.Equals(left.ColumnName, right.ColumnName, StringComparison.Ordinal)) + { + return true; + } + + return (!left.CaseSensitive || !right.CaseSensitive) && + string.Equals(left.ColumnName, right.ColumnName, StringComparison.OrdinalIgnoreCase); + } + + private static bool TryCreateDirectConstructorMapInvocation( + InvocationExpressionSyntax mapInvocation, + SemanticModel semanticModel, + MemberPathInfo memberPath, + System.Threading.CancellationToken cancellationToken, + out MapInvocation result) + { + result = null; + + var statement = mapInvocation.FirstAncestorOrSelf(); + var block = statement?.Parent as BlockSyntax; + var constructor = block?.Parent as ConstructorDeclarationSyntax; + if (statement == null || constructor == null) + { + return false; + } + + var constructorSymbol = semanticModel.GetDeclaredSymbol(constructor, cancellationToken); + if (constructorSymbol == null) + { + return false; + } + + var column = memberPath.TerminalName; + var columnKnown = true; + var caseSensitive = true; + var ignored = false; + var columnLocation = mapInvocation.GetLocation(); + + SyntaxNode current = mapInvocation; + while (current.Parent is MemberAccessExpressionSyntax memberAccess && + memberAccess.Expression == current && + memberAccess.Parent is InvocationExpressionSyntax chainedInvocation) + { + var chainedMethod = semanticModel.GetSymbolInfo(chainedInvocation, cancellationToken).Symbol as IMethodSymbol; + if (chainedMethod == null) + { + return false; + } + + if (IsToColumnInvocation(chainedMethod)) + { + columnLocation = chainedInvocation.GetLocation(); + if (!TryGetColumn(chainedInvocation, semanticModel, cancellationToken, out column, out caseSensitive)) + { + columnKnown = false; + } + } + else if (IsIgnoreInvocation(chainedMethod)) + { + ignored = true; + } + + current = chainedInvocation; + } + + if (current != statement.Expression) + { + return false; + } + + result = new MapInvocation( + constructorSymbol, + memberPath, + column, + columnKnown, + caseSensitive, + ignored, + mapInvocation.GetLocation(), + columnLocation); + return true; + } + + private static bool TryGetColumn( + InvocationExpressionSyntax invocation, + SemanticModel semanticModel, + System.Threading.CancellationToken cancellationToken, + out string column, + out bool caseSensitive) + { + column = null; + caseSensitive = true; + + if (invocation.ArgumentList.Arguments.Count == 0) + { + return false; + } + + var columnConstant = semanticModel.GetConstantValue( + invocation.ArgumentList.Arguments[0].Expression, + cancellationToken); + if (!columnConstant.HasValue || !(columnConstant.Value is string columnValue)) + { + return false; + } + + column = columnValue; + + foreach (var argument in invocation.ArgumentList.Arguments.Skip(1)) + { + var name = argument.NameColon?.Name.Identifier.ValueText; + if (name != null && name != "caseSensitive") + { + continue; + } + + var caseConstant = semanticModel.GetConstantValue(argument.Expression, cancellationToken); + if (!caseConstant.HasValue || !(caseConstant.Value is bool caseValue)) + { + return false; + } + + caseSensitive = caseValue; + return true; + } + + return true; + } + + private static bool TryGetLambda(ExpressionSyntax expression, out LambdaExpressionSyntax lambda) + { + expression = StripCastsAndParentheses(expression); + lambda = expression as LambdaExpressionSyntax; + return lambda != null; + } + + private static bool TryCreateMemberPath( + CSharpSyntaxNode body, + SemanticModel semanticModel, + System.Threading.CancellationToken cancellationToken, + out MemberPathInfo memberPath, + out string reason) + { + memberPath = null; + reason = null; + + var expression = StripCastsAndParentheses(body as ExpressionSyntax); + var properties = new Stack(); + + while (expression != null) + { + if (expression is MemberAccessExpressionSyntax memberAccess) + { + var symbol = semanticModel.GetSymbolInfo(memberAccess, cancellationToken).Symbol; + var property = symbol as IPropertySymbol; + if (property == null) + { + reason = symbol == null + ? "the member could not be resolved statically" + : $"member '{symbol.Name}' is not a property"; + return false; + } + + if (property.IsIndexer || property.Parameters.Length > 0) + { + reason = $"indexed property '{property.Name}' is not supported"; + return false; + } + + properties.Push(property); + expression = StripCastsAndParentheses(memberAccess.Expression); + continue; + } + + if (expression is IdentifierNameSyntax identifier) + { + var symbol = semanticModel.GetSymbolInfo(identifier, cancellationToken).Symbol; + if (symbol is IParameterSymbol && properties.Count > 0) + { + memberPath = MemberPathInfo.Create(properties); + return true; + } + + reason = "the expression must resolve to a property path rooted in the entity parameter"; + return false; + } + + reason = "the expression must resolve to a property path"; + return false; + } + + reason = "the expression must resolve to a property path"; + return false; + } + + private static ExpressionSyntax StripCastsAndParentheses(ExpressionSyntax expression) + { + while (true) + { + if (expression is ParenthesizedExpressionSyntax parenthesized) + { + expression = parenthesized.Expression; + continue; + } + + if (expression is CastExpressionSyntax cast) + { + expression = cast.Expression; + continue; + } + + return expression; + } + } + + private static bool IsMapInvocation(IMethodSymbol method) + { + return method.Name == "Map" && + method.Parameters.Length == 1 && + IsType(method.ContainingType.OriginalDefinition, MappingNamespace, "EntityMapBase`2"); + } + + private static bool IsIncludeBaseInvocation(IMethodSymbol method) + { + return method.Name == "IncludeBase" && + method.IsGenericMethod && + method.TypeArguments.Length == 1 && + method.Parameters.Length == 0 && + IsType(method.ContainingType.OriginalDefinition, MappingNamespace, "EntityMapBase`2"); + } + + private static bool IsGenericAddMapInvocation(IMethodSymbol method) + { + return method.Name == "AddMap" && + method.IsGenericMethod && + method.TypeArguments.Length == 1 && + method.Parameters.Length == 0 && + IsType(method.ContainingType, ConfigurationNamespace, "FluentMapConfiguration"); + } + + private static bool IsToColumnInvocation(IMethodSymbol method) + { + return method.Name == "ToColumn" && + method.Parameters.Length >= 1 && + method.Parameters[0].Type.SpecialType == SpecialType.System_String; + } + + private static bool IsIgnoreInvocation(IMethodSymbol method) + { + return method.Name == "Ignore" && method.Parameters.Length == 0; + } + + private static INamedTypeSymbol FindEntityType(INamedTypeSymbol mapType) + { + for (var current = mapType; current != null; current = current.BaseType) + { + if (IsType(current.OriginalDefinition, MappingNamespace, "EntityMapBase`2") || + IsType(current.OriginalDefinition, MappingNamespace, "EntityMap`1")) + { + return current.TypeArguments[0] as INamedTypeSymbol; + } + } + + return null; + } + + private static bool IsAssignableTo(INamedTypeSymbol type, INamedTypeSymbol baseType) + { + for (var current = type.BaseType; current != null; current = current.BaseType) + { + if (SymbolEqualityComparer.Default.Equals(current, baseType)) + { + return true; + } + } + + return false; + } + + private static bool IsType(INamedTypeSymbol type, string namespaceName, string metadataName) + { + return type != null && + type.MetadataName == metadataName && + type.ContainingNamespace.ToDisplayString() == namespaceName; + } + + private static string FormatSymbol(ISymbol symbol) + { + return symbol.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat); + } + + private sealed class MapInvocation + { + internal MapInvocation( + IMethodSymbol constructor, + MemberPathInfo memberPath, + string columnName, + bool columnKnown, + bool caseSensitive, + bool ignored, + Location invocationLocation, + Location columnLocation) + { + Constructor = constructor; + MemberPath = memberPath; + ColumnName = columnName; + ColumnKnown = columnKnown; + CaseSensitive = caseSensitive; + Ignored = ignored; + InvocationLocation = invocationLocation; + ColumnLocation = columnLocation; + } + + internal IMethodSymbol Constructor { get; } + + internal MemberPathInfo MemberPath { get; } + + internal string ColumnName { get; } + + internal bool ColumnKnown { get; } + + internal bool CaseSensitive { get; } + + internal bool Ignored { get; } + + internal Location InvocationLocation { get; } + + internal Location ColumnLocation { get; } + } + + private sealed class MemberPathInfo + { + private MemberPathInfo(string key, string display, string terminalName) + { + Key = key; + Display = display; + TerminalName = terminalName; + } + + internal string Key { get; } + + internal string Display { get; } + + internal string TerminalName { get; } + + internal static MemberPathInfo Create(IEnumerable properties) + { + var propertyList = properties.ToList(); + var key = string.Join( + ".", + propertyList.Select(property => property.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + "." + property.MetadataName)); + var display = string.Join(".", propertyList.Select(property => property.Name)); + return new MemberPathInfo(key, display, propertyList[propertyList.Count - 1].Name); + } + } + } +} diff --git a/src/Dapper.FluentMap.Analyzers/README.md b/src/Dapper.FluentMap.Analyzers/README.md new file mode 100644 index 0000000..a5f5e19 --- /dev/null +++ b/src/Dapper.FluentMap.Analyzers/README.md @@ -0,0 +1,5 @@ +# Dapper.FluentMap.Analyzers + +Roslyn analyzers for statically provable `Dapper.FluentMap` configuration errors. + +The analyzer package complements runtime validation. It does not execute user mapping constructors, scan assemblies, access databases or replace `FluentMapper.Validate()`. diff --git a/test/Dapper.FluentMap.Analyzers.Tests/Dapper.FluentMap.Analyzers.Tests.csproj b/test/Dapper.FluentMap.Analyzers.Tests/Dapper.FluentMap.Analyzers.Tests.csproj new file mode 100644 index 0000000..f2dc9d9 --- /dev/null +++ b/test/Dapper.FluentMap.Analyzers.Tests/Dapper.FluentMap.Analyzers.Tests.csproj @@ -0,0 +1,17 @@ + + + net10.0 + false + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + diff --git a/test/Dapper.FluentMap.Analyzers.Tests/FluentMapConfigurationAnalyzerTests.cs b/test/Dapper.FluentMap.Analyzers.Tests/FluentMapConfigurationAnalyzerTests.cs new file mode 100644 index 0000000..d09b6fd --- /dev/null +++ b/test/Dapper.FluentMap.Analyzers.Tests/FluentMapConfigurationAnalyzerTests.cs @@ -0,0 +1,337 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Dapper.FluentMap; +using Dapper.FluentMap.Analyzers; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Xunit; + +namespace Dapper.FluentMap.Analyzers.Tests +{ + public sealed class FluentMapConfigurationAnalyzerTests + { + [Fact] + public async Task InvalidMapExpressionShouldReportDfm001() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public string Name { get; set; } + + public string GetName() => Name; +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(c => c.GetName()).ToColumn(""customer_name""); + } +}"; + + var diagnostic = await GetSingleDiagnosticAsync(source, FluentMapConfigurationAnalyzer.InvalidMapExpressionDiagnosticId); + + AssertDiagnostic(diagnostic, DiagnosticSeverity.Error, "Map expression 'c.GetName()' is invalid"); + AssertDiagnosticLineContains(source, diagnostic, "Map(c => c.GetName()).ToColumn"); + } + + [Fact] + public async Task DuplicateMemberPathShouldReportDfm002() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public int Id { get; set; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(c => c.Id).ToColumn(""customer_id""); + Map(c => c.Id).ToColumn(""other_id""); + } +}"; + + var diagnostic = await GetSingleDiagnosticAsync(source, FluentMapConfigurationAnalyzer.DuplicateMemberPathDiagnosticId); + + AssertDiagnostic(diagnostic, DiagnosticSeverity.Error, "Property path 'Id' is mapped more than once"); + AssertDiagnosticLineContains(source, diagnostic, "Map(c => c.Id).ToColumn(\"other_id\")"); + } + + [Fact] + public async Task DuplicateColumnShouldReportDfm003() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public int Id { get; set; } + + public string Name { get; set; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(c => c.Id).ToColumn(""shared_column""); + Map(c => c.Name).ToColumn(""shared_column""); + } +}"; + + var diagnostic = await GetSingleDiagnosticAsync(source, FluentMapConfigurationAnalyzer.DuplicateColumnDiagnosticId); + + AssertDiagnostic(diagnostic, DiagnosticSeverity.Error, "Column 'shared_column' is mapped by more than one property path"); + AssertDiagnosticLineContains(source, diagnostic, "Map(c => c.Name).ToColumn(\"shared_column\")"); + } + + [Fact] + public async Task InvalidIncludeBaseShouldReportDfm004() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public class CustomerBase +{ + public int Id { get; set; } +} + +public sealed class Customer : CustomerBase +{ + public string Name { get; set; } +} + +public sealed class OtherCustomer +{ + public int Id { get; set; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + IncludeBase(); + } +}"; + + var diagnostic = await GetSingleDiagnosticAsync(source, FluentMapConfigurationAnalyzer.InvalidIncludeBaseDiagnosticId); + + AssertDiagnostic(diagnostic, DiagnosticSeverity.Error, "Type 'OtherCustomer' cannot be included as a base mapping for entity 'Customer'"); + AssertDiagnosticLineContains(source, diagnostic, "IncludeBase()"); + } + + [Fact] + public async Task InvalidGenericMapRegistrationShouldReportDfm005() + { + var source = @" +using System.Collections.Generic; +using Dapper.FluentMap.Configuration; +using Dapper.FluentMap.Mapping; + +public sealed class NonGenericMap : IEntityMap +{ + public IList PropertyMaps { get; } = new List(); +} + +public sealed class Startup +{ + public void Configure(FluentMapConfiguration configuration) + { + configuration.AddMap(); + } +}"; + + var diagnostic = await GetSingleDiagnosticAsync(source, FluentMapConfigurationAnalyzer.InvalidGenericMapRegistrationDiagnosticId); + + AssertDiagnostic(diagnostic, DiagnosticSeverity.Error, "Entity map type 'NonGenericMap' must implement exactly one closed IEntityMap interface"); + AssertDiagnosticLineContains(source, diagnostic, "configuration.AddMap()"); + } + + [Fact] + public async Task ValidMappingConfigurationShouldNotReportDiagnostics() + { + var source = @" +using Dapper.FluentMap.Configuration; +using Dapper.FluentMap.Mapping; + +public sealed record ConstructorCustomer(int Id, string Name); + +public class CustomerBase +{ + public int Id { get; set; } +} + +public sealed class Customer : CustomerBase +{ + public string Name { get; set; } + + public Rank Rank { get; set; } + + public Seniority Seniority { get; set; } +} + +public sealed class Rank +{ + public int Level { get; set; } +} + +public sealed class Seniority +{ + public int Level { get; set; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + IncludeBase(); + Map(c => c.Name).ToColumn(""customer_name""); + Map(c => c.Rank.Level).ToColumn(""rank_level""); + Map(c => c.Seniority.Level).ToColumn(""seniority_level""); + } +} + +public sealed class CustomerBaseMap : EntityMap +{ + public CustomerBaseMap() + { + Map(c => c.Id).ToColumn(""customer_id""); + } +} + +public sealed class ConstructorCustomerMap : EntityMap +{ + public ConstructorCustomerMap() + { + Map(c => c.Id).ToColumn(""constructor_customer_id""); + } +} + +public sealed class Startup +{ + public void Configure(FluentMapConfiguration configuration) + { + configuration + .AddMap() + .AddMap() + .AddMap(); + } +}"; + + var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + + Assert.Empty(diagnostics); + } + + [Fact] + public async Task CaseSensitiveColumnNamesWithDifferentCasingShouldNotReportDfm003() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public int Id { get; set; } + + public string Name { get; set; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(c => c.Id).ToColumn(""Customer""); + Map(c => c.Name).ToColumn(""customer""); + } +}"; + + var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + + Assert.DoesNotContain(diagnostics, diagnostic => diagnostic.Id == FluentMapConfigurationAnalyzer.DuplicateColumnDiagnosticId); + } + + private static async Task GetSingleDiagnosticAsync(string source, string diagnosticId) + { + var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + return Assert.Single(diagnostics, diagnostic => diagnostic.Id == diagnosticId); + } + + private static async Task> GetAnalyzerDiagnosticsAsync(string source) + { + var syntaxTree = CSharpSyntaxTree.ParseText( + source, + CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Preview), + path: "Test0.cs"); + + var references = GetMetadataReferences(); + var compilation = CSharpCompilation.Create( + "AnalyzerTest", + new[] { syntaxTree }, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var compilerErrors = compilation + .GetDiagnostics() + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Select(diagnostic => diagnostic.ToString()) + .ToList(); + + Assert.Empty(compilerErrors); + + var analyzer = new FluentMapConfigurationAnalyzer(); + var compilationWithAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer)); + var diagnostics = await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(); + + return diagnostics + .Where(diagnostic => diagnostic.Id.StartsWith("DFM", StringComparison.Ordinal)) + .OrderBy(diagnostic => diagnostic.Id, StringComparer.Ordinal) + .ThenBy(diagnostic => diagnostic.Location.SourceSpan.Start) + .ToList(); + } + + private static IReadOnlyList GetMetadataReferences() + { + var trustedPlatformAssemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")) + .Split(Path.PathSeparator) + .Select(path => MetadataReference.CreateFromFile(path)); + + var explicitAssemblies = new[] + { + typeof(FluentMapper).Assembly.Location, + typeof(Dapper.SqlMapper).Assembly.Location + } + .Select(path => MetadataReference.CreateFromFile(path)); + + return trustedPlatformAssemblies + .Concat(explicitAssemblies) + .GroupBy(reference => reference.Display, StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .ToList(); + } + + private static void AssertDiagnostic(Diagnostic diagnostic, DiagnosticSeverity severity, string messageFragment) + { + Assert.Equal(severity, diagnostic.Severity); + Assert.Contains(messageFragment, diagnostic.GetMessage(), StringComparison.Ordinal); + } + + private static void AssertDiagnosticLineContains(string source, Diagnostic diagnostic, string expectedLineFragment) + { + var line = diagnostic.Location.GetLineSpan().StartLinePosition.Line; + var sourceLine = source.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None)[line]; + + Assert.Contains(expectedLineFragment, sourceLine, StringComparison.Ordinal); + } + } +} From d559d65e0f5da1a45c41ff9296cbef28705ce12a Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sun, 26 Jul 2026 11:43:42 -0300 Subject: [PATCH 09/20] refactor: improve trimming and AOT compatibility --- Dapper.FluentMap.sln | 7 + README.md | 13 + docs/sdd/etapa-4/02-trimming-aot.md | 326 ++++++++++++++++++ docs/sdd/etapa-4/decisions.md | 8 + docs/sdd/etapa-4/status.md | 2 +- .../Compatibility/CodeAnalysisAttributes.cs | 61 ++++ .../FluentConventionConfiguration.cs | 14 +- .../Configuration/FluentMapConfiguration.cs | 34 +- src/Dapper.FluentMap/FluentMapper.cs | 9 +- src/Dapper.FluentMap/Mapping/EntityMap.cs | 14 +- src/Dapper.FluentMap/MappingRegistry.cs | 19 +- .../TypeMaps/FluentMapTypeMap.cs | 27 ++ .../Utils/FluentMapConfigurationExtensions.cs | 5 + .../Dapper.FluentMap.AotSmoke.csproj | 16 + test/Dapper.FluentMap.AotSmoke/Program.cs | 112 ++++++ .../ManualMappingTests.cs | 4 +- .../MappingRegistrationTests.cs | 5 +- 17 files changed, 658 insertions(+), 18 deletions(-) create mode 100644 docs/sdd/etapa-4/02-trimming-aot.md create mode 100644 src/Dapper.FluentMap/Compatibility/CodeAnalysisAttributes.cs create mode 100644 src/Dapper.FluentMap/TypeMaps/FluentMapTypeMap.cs create mode 100644 test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj create mode 100644 test/Dapper.FluentMap.AotSmoke/Program.cs diff --git a/Dapper.FluentMap.sln b/Dapper.FluentMap.sln index 75312d2..e44aef5 100644 --- a/Dapper.FluentMap.sln +++ b/Dapper.FluentMap.sln @@ -23,6 +23,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapper.FluentMap.Analyzers" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapper.FluentMap.Analyzers.Tests", "test\Dapper.FluentMap.Analyzers.Tests\Dapper.FluentMap.Analyzers.Tests.csproj", "{F5059D11-D45B-4793-B6E0-7758F57AC0E1}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dapper.FluentMap.AotSmoke", "test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj", "{2E23213D-A547-4FF6-BB58-8793860C18FE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -53,6 +55,10 @@ Global {F5059D11-D45B-4793-B6E0-7758F57AC0E1}.Debug|Any CPU.Build.0 = Debug|Any CPU {F5059D11-D45B-4793-B6E0-7758F57AC0E1}.Release|Any CPU.ActiveCfg = Release|Any CPU {F5059D11-D45B-4793-B6E0-7758F57AC0E1}.Release|Any CPU.Build.0 = Release|Any CPU + {2E23213D-A547-4FF6-BB58-8793860C18FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2E23213D-A547-4FF6-BB58-8793860C18FE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2E23213D-A547-4FF6-BB58-8793860C18FE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2E23213D-A547-4FF6-BB58-8793860C18FE}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -64,6 +70,7 @@ Global {DFB62D87-9A74-40DF-A930-8F61A53E0F1B} = {742442F2-CAE7-4DC8-BD73-8C54C0005A53} {424B90AD-406E-4CC1-B0F4-917F47A06E4D} = {580E3446-6579-4414-9875-970849E635E5} {F5059D11-D45B-4793-B6E0-7758F57AC0E1} = {742442F2-CAE7-4DC8-BD73-8C54C0005A53} + {2E23213D-A547-4FF6-BB58-8793860C18FE} = {742442F2-CAE7-4DC8-BD73-8C54C0005A53} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {10834736-59FD-47FF-9344-096247DC48CD} diff --git a/README.md b/README.md index de44566..871ee40 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,19 @@ FluentMapper.Initialize(config => }); ``` +#### Trimming and Native AOT +For applications published with IL trimming, single-file or Native AOT, prefer explicit registration: + +```csharp +FluentMapper.Initialize(config => + { + config.AddMap(); + config.AddConvention().ForEntity(); + }); +``` + +Assembly scanning APIs such as `AddMapsFromAssembly(...)`, `AddMapsFromAssemblyContaining()`, `ForEntitiesInAssembly(...)`, `ForEntitiesInCurrentAssembly(...)` and the legacy `ApplyMapsFromAssemblies(...)` depend on reflection discovery and are annotated as trimming-sensitive. They remain supported for normal runtime usage, but they can warn or fail after trimming if discovered types or metadata are removed. + #### Convention based mapping When you have a lot of entity types, creating manual mapping classes can become plumbing. If your column names adhere to some kind of naming convention, you might be better off by configuring a mapping convention. diff --git a/docs/sdd/etapa-4/02-trimming-aot.md b/docs/sdd/etapa-4/02-trimming-aot.md new file mode 100644 index 0000000..5e7ecd6 --- /dev/null +++ b/docs/sdd/etapa-4/02-trimming-aot.md @@ -0,0 +1,326 @@ +# 02 - Trimming E Native AOT + +## Specification + +Esta entrega mediu e melhorou a compatibilidade do core `Dapper.FluentMap` com IL trimming, single-file e Native AOT, preservando o target `netstandard2.0`. + +Objetivos tratados: + +- medir baseline de publish trimmed e Native AOT em um consumidor pequeno; +- separar o caminho explicito de registro do caminho por assembly scanning; +- remover reflection redundante no registro do type map interno; +- adicionar annotations de trimming quando o contrato e verificavel; +- marcar APIs de scanning como dependentes de reflection; +- documentar warnings de propriedade do FluentMap e do Dapper. + +Fora do objetivo: + +- declarar compatibilidade Native AOT completa; +- alterar o target do core para `net10.0`; +- criar source generator; +- corrigir warnings internos do Dapper; +- tornar assembly scanning seguro a qualquer custo. + +## Discovery + +Arquivos e contexto analisados: + +- `AGENTS.md` +- `.agents/skills/dotnet-aot-compat/SKILL.md` +- `.agents/skills/dotnet-aot-compat/references/polyfills.md` +- `.agents/skills/run-tests/SKILL.md` +- `.agents/skills/msbuild-antipatterns/SKILL.md` +- `docs/sdd/etapa-1/README.md` +- `docs/sdd/etapa-1/decisions.md` +- `docs/sdd/etapa-2/README.md` +- `docs/sdd/etapa-2/decisions.md` +- `docs/sdd/etapa-3/README.md` +- `docs/sdd/etapa-3/decisions.md` +- `docs/sdd/etapa-3/01-mapping-registration.md` +- `docs/sdd/etapa-4/README.md` +- `docs/sdd/etapa-4/status.md` +- `docs/sdd/etapa-4/decisions.md` +- `docs/sdd/etapa-4/01-roslyn-analyzers.md` +- `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs` +- `src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs` +- `src/Dapper.FluentMap/Mapping/EntityMap.cs` +- `src/Dapper.FluentMap/MappingRegistry.cs` +- `src/Dapper.FluentMap/TypeMaps/*` +- `src/Dapper.FluentMap/Utils/FluentMapConfigurationExtensions.cs` +- `src/Dapper.FluentMap/Utils/ReflectionHelper.cs` + +Entrega 1 confirmada: + +- `docs/sdd/etapa-4/status.md` registrava `01 - Roslyn Analyzers` como `Concluido`; +- `docs/sdd/etapa-4/01-roslyn-analyzers.md` registrava validacao completa e pacote de analyzer inspecionado. + +Busca executada: + +```text +rg -n "Assembly|GetTypes|GetExportedTypes|GetMember|GetProperty|GetConstructor|Activator\.CreateInstance|MakeGenericMethod|\.Invoke\(|CreateDelegate|Dynamic|Expression\.Compile|Type\.GetType|RuntimeTypeHandle|MakeGenericType|PropertyInfo|MemberInfo" src test docs\sdd\etapa-4 -g "*.cs" -g "*.csproj" -g "*.md" +``` + +Classificacao dos usos relevantes no core: + +| Uso | Local | Classificacao | Decisao | +|---|---|---|---| +| `Assembly.GetExportedTypes()` | `FluentMapConfiguration.AddMapsFromAssembly(...)` | Reflection-dependent por design | API marcada com `RequiresUnreferencedCode`; scanning documentado como trimming-sensitive | +| `Assembly.GetCallingAssembly().GetExportedTypes()` | `FluentConventionConfiguration.ForEntitiesInCurrentAssembly(...)` | Reflection-dependent por design | API marcada com `RequiresUnreferencedCode` | +| `Assembly.GetExportedTypes()` para conventions | `FluentConventionConfiguration.ForEntitiesInAssembly(...)` | Reflection-dependent por design | API marcada com `RequiresUnreferencedCode` | +| `Assembly.GetTypes()` | `FluentMapConfigurationExtensions.ApplyMapsFromAssemblies(...)` | Reflection-dependent por design | API legada marcada com `RequiresUnreferencedCode` | +| `Activator.CreateInstance(mapType)` | Scanning de maps | Trimming-sensitive | Mantido apenas no caminho de scanning e coberto pelo aviso da API | +| `Activator.CreateInstance(typeof(FluentMapTypeMap<>).MakeGenericType(type))` | `MappingRegistry.SetDapperTypeMap(...)` | Pode ser removido | Substituido por type map interno nao generico | +| `MakeGenericMethod(...).Invoke(...)` | `ApplyMapsFromAssemblies(...)` legado | Reflection-dependent por design | Mantido por compatibilidade e marcado como trimming-sensitive | +| `Type.GetInterfaces()` | `AddMap()` | Trimming-sensitive, anotavel | `TMap` anotado com preservacao de `Interfaces` | +| `Type.GetProperties(...)` | `ForEntity()` e `Explain()` | Trimming-sensitive, anotavel | `ForEntity()`, `MapProperties(...)`, `Explain()` e helpers anotados | +| `Type.GetConstructors(...)` | `Explain()` | Trimming-sensitive, anotavel | `Explain()` e helpers anotados | +| `PropertyInfo` / `MemberInfo` via expression tree | `ReflectionHelper`, `MemberPath`, `PropertyMap` | Reflection metadata por contrato | Mantido; nao faz scanning nem busca ampla por nome | +| `ConstructorInfo` / `ParameterInfo` recebidos do Dapper | `FluentConstructorTypeMap` | AOT-safe no FluentMap; depende do Dapper para discovery | Mantido; warnings restantes classificados como dependency-owned | + +Areas especiais: + +- Assembly scanning: permanece convenience reflection-dependent. +- Registro generico: `AddMap()` e o caminho recomendado; usa reflection limitada para inferir `IEntityMap`, agora anotada e sem warning do FluentMap no smoke trimmed explicito. +- Constructor mapping: nao materializa objetos; traduz metadata para o Dapper. Warnings de discovery de construtor no smoke trimmed vem de `Dapper.DefaultTypeMap`. +- MemberPath: usa `PropertyInfo` obtido da expression ou de convention ja configurada; nao adiciona scanning. +- Convention discovery: `ForEntity()` e o caminho explicito anotado; `ForEntitiesInAssembly(...)` e `ForEntitiesInCurrentAssembly(...)` continuam dependentes de scanning. +- Analyzer: fica isolado em `Dapper.FluentMap.Analyzers` e nao altera runtime do core. +- `Explain()`: faz diagnostico por reflection sobre propriedades/construtores publicos e foi anotado. + +## Baseline + +Ambiente: + +- SDK: `10.0.302` +- Runtime alvo do consumidor smoke: `net10.0` +- RID usado: `win-x64` +- Core: `netstandard2.0` + +Baseline por `ProjectReference`: + +```text +dotnet publish ... -p:PublishTrimmed=true +``` + +Resultado: + +- falhou antes da analise do consumidor com `NETSDK1124`, porque `PublishTrimmed` foi propagado ao projeto `src/Dapper.FluentMap` `netstandard2.0`; +- `dotnet publish ... -p:PublishAot=true` falhou com `NETSDK1207` pelo mesmo motivo conceitual. + +Baseline usando referencia direta ao assembly compilado do core: + +```text +dotnet build .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release +dotnet publish --configuration Release --runtime win-x64 --self-contained true -p:PublishTrimmed=true -p:TrimMode=full -p:PublishAot=false -p:TrimmerSingleWarn=false -p:ILLinkTreatWarningsAsErrors=false +``` + +Resultado trimmed antes das mudancas: + +- publish concluido; +- runtime executou `explicit:Id` e `scanning:Id` no consumidor combinado; +- warnings FluentMap-owned: + - `IL2067` em `FluentMapConfiguration.CreateEntityMap(Type)` por `Activator.CreateInstance(Type)`; + - `IL2026` em `FluentMapConfiguration.GetExportedTypes(Assembly)` por `Assembly.GetExportedTypes()`; + - `IL2070` em `FluentMapConfiguration.GetMappedEntityType(Type)` por `Type.GetInterfaces()`. +- warnings dependency-owned: + - `IL2046`, `IL2092`, `IL2075`, `IL2070` em fontes do Dapper. + +Baseline Native AOT antes das mudancas: + +```text +dotnet publish --configuration Release --runtime win-x64 --self-contained true -p:PublishAot=true +``` + +Resultado: + +- build gerou o DLL intermediario do consumidor; +- publish falhou no ambiente com `Platform linker not found`; +- runtime Native AOT nao foi validado porque faltam os pre-requisitos de toolchain C++ para Native AOT no Windows. + +## Decision + +Registro explicito: + +- `AddMap()` permanece a API explicita moderna; +- `TMap` recebeu annotation para preservar interfaces, permitindo a inferencia de `IEntityMap` sem warning do FluentMap no smoke trimmed explicito; +- a instancia do map passou a ser criada por `new TMap()`, removendo `Activator.CreateInstance` do caminho explicito. + +Assembly scanning: + +- `AddMapsFromAssembly(...)`, `AddMapsFromAssemblyContaining()`, `ForEntitiesInAssembly(...)`, `ForEntitiesInCurrentAssembly(...)` e `ApplyMapsFromAssemblies(...)` foram marcados com `RequiresUnreferencedCode`; +- scanning continua suportado em runtime normal; +- scanning trimmed pode falhar em runtime quando o trimmer remove tipos, interfaces ou construtores que so seriam descobertos por reflection; +- nao foi usado `UnconditionalSuppressMessage`, `NoWarn`, `SuppressTrimAnalysisWarnings` ou `#pragma`. + +Type map interno: + +- o registry deixou de criar `FluentMapTypeMap` por `MakeGenericType` + `Activator.CreateInstance`; +- foi adicionado um type map interno nao generico para registro no Dapper; +- a classe publica `FluentMapTypeMap` permanece para compatibilidade. + +Annotations: + +- polyfills internos foram adicionados para manter `netstandard2.0`; +- `DynamicallyAccessedMembers` foi usado apenas onde o fluxo e verificavel; +- `RequiresUnreferencedCode` foi usado em APIs de scanning e helpers privados exclusivos desse caminho; +- `Explain()` foi anotado porque enumera propriedades e construtores publicos. + +Impacto publico: + +- nenhuma API publica foi removida; +- annotations em APIs publicas passam a expor warnings corretos para consumidores trimmed/AOT; +- scanning agora avisa o consumidor em publish trimmed/AOT em vez de esconder a fragilidade. + +## Delivery + +Arquivos adicionados: + +- `src/Dapper.FluentMap/Compatibility/CodeAnalysisAttributes.cs` +- `src/Dapper.FluentMap/TypeMaps/FluentMapTypeMap.cs` +- `test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj` +- `test/Dapper.FluentMap.AotSmoke/Program.cs` +- `docs/sdd/etapa-4/02-trimming-aot.md` + +Arquivos alterados: + +- `Dapper.FluentMap.sln` +- `README.md` +- `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs` +- `src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs` +- `src/Dapper.FluentMap/FluentMapper.cs` +- `src/Dapper.FluentMap/Mapping/EntityMap.cs` +- `src/Dapper.FluentMap/MappingRegistry.cs` +- `src/Dapper.FluentMap/Utils/FluentMapConfigurationExtensions.cs` +- `docs/sdd/etapa-4/decisions.md` +- `docs/sdd/etapa-4/status.md` + +Comportamento preservado: + +- core continua `netstandard2.0`; +- `AddMap(new CustomerMap())` permanece; +- `AddMap()` permanece; +- assembly scanning continua funcionando em runtime normal; +- conventions explicitas e naming policies continuam funcionando; +- constructor mapping continua delegado ao Dapper. + +Comportamento/documentacao alterados: + +- APIs de scanning emitem warning de trimming/AOT via `RequiresUnreferencedCode`; +- registro explicito nao emite warnings FluentMap-owned no smoke trimmed; +- registry nao depende mais de `MakeGenericType` + `Activator.CreateInstance` para instalar o type map interno. + +## Validation + +Validacao localizada executada durante a entrega: + +```text +dotnet build .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release --no-restore +``` + +Resultado: + +- sucesso, 0 warnings, 0 erros. + +Smoke normal: + +```text +dotnet run --project .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release +dotnet run --project .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:DefineConstants=AOT_SMOKE_SCANNING +``` + +Resultado: + +- `explicit:ok`; +- `scanning:ok`. + +Publish trimmed explicito: + +```text +dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishTrimmed=true -p:TrimMode=full -p:PublishAot=false -p:TrimmerSingleWarn=false -p:ILLinkTreatWarningsAsErrors=false +.\test\Dapper.FluentMap.AotSmoke\bin\Release\net10.0\win-x64\publish\Dapper.FluentMap.AotSmoke.exe +``` + +Resultado: + +- publish concluido; +- runtime: `explicit:ok`; +- 0 warnings FluentMap-owned; +- warnings restantes dependency-owned no Dapper: `IL2080`, `IL2046`, `IL2092`, `IL2075`, `IL2070`. + +Publish trimmed scanning: + +```text +dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishTrimmed=true -p:TrimMode=full -p:PublishAot=false -p:DefineConstants=AOT_SMOKE_SCANNING -p:TrimmerSingleWarn=false -p:ILLinkTreatWarningsAsErrors=false +.\test\Dapper.FluentMap.AotSmoke\bin\Release\net10.0\win-x64\publish\Dapper.FluentMap.AotSmoke.exe +``` + +Resultado: + +- publish concluido; +- warning FluentMap-owned esperado: `IL2026` na chamada de `AddMapsFromAssemblyContaining()`; +- runtime falhou: `Column 'customer_id' was not mapped to property 'Id'.`; +- falha confirma que scanning depende de metadata que pode ser removida pelo trimmer. + +Publish Native AOT explicito: + +```text +dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishAot=true -p:TrimmerSingleWarn=false -p:ILLinkTreatWarningsAsErrors=false -p:MSBuildWarningsAsMessages= +``` + +Resultado: + +- build gerou `Dapper.FluentMap.AotSmoke.dll`; +- publish falhou com `Platform linker not found`; +- runtime Native AOT nao foi validado neste ambiente. + +Validacao final completa: + +```text +dotnet restore +dotnet build +dotnet test +dotnet build --configuration Release +dotnet test --configuration Release +dotnet pack .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release --no-build --output .\artifacts\packages +``` + +Resultado: + +- `dotnet restore`: sucesso; +- `dotnet build`: sucesso, 0 warnings, 0 erros; +- `dotnet test`: sucesso, 128 testes do core, 7 testes Dommel e 7 testes do analyzer; +- `dotnet build --configuration Release`: sucesso, 0 warnings, 0 erros; +- `dotnet test --configuration Release`: sucesso, 128 testes do core, 7 testes Dommel e 7 testes do analyzer. +- `dotnet pack`: pacote `Dapper.FluentMap.2.0.0.nupkg` criado; warning existente `NU5125` sobre `PackageLicenseUrl` legado. + +Inspecao do pacote: + +- contem `lib/netstandard2.0/Dapper.FluentMap.dll`; +- contem `lib/netstandard2.0/Dapper.FluentMap.xml`; +- nuspec mantem dependencia `Dapper` `2.1.79` para `.NETStandard2.0`; +- nao contem projetos de teste nem o smoke app. + +Confirmacoes: + +- `src/Dapper.FluentMap/Dapper.FluentMap.csproj` continua com `TargetFrameworks` igual a `netstandard2.0`; +- registro explicito nao ganhou assembly scanning; +- nenhum warning foi silenciado por `NoWarn`, `SuppressTrimAnalysisWarnings`, `UnconditionalSuppressMessage` ou `#pragma`; +- APIs reflection-heavy estao anotadas e documentadas; +- Dommel nao recebeu alteracao funcional. + +## Matriz + +| Funcionalidade | Normal | Trimmed | Native AOT | Observacao | +|---|---|---|---|---| +| Registro explicito | Suportado | Publica e executa no smoke; sem warnings FluentMap-owned | Publish bloqueado pelo linker ausente; sem runtime validado | Caminho recomendado para trimmed/AOT | +| Assembly scanning | Suportado | Publica com `IL2026` e falha no smoke scanning trimmed | Nao validado em runtime; tratado como reflection-dependent | Mantido como convenience, nao como caminho AOT-friendly | +| Naming policies | Suportado via `ForEntity()` | Validado no smoke explicito trimmed | Nao validado em runtime | `ForEntitiesInAssembly(...)` segue trimming-sensitive | +| Constructor mapping | Suportado | Validado no smoke explicito trimmed; warnings restantes vem do Dapper | Nao validado em runtime | FluentMap traduz metadata; Dapper faz discovery final | + +## Limitacoes Restantes + +- Native AOT runtime nao foi executado porque o ambiente nao possui o linker C++ exigido pelo SDK. +- Dapper ainda emite warnings de trimming/AOT no smoke; esses warnings nao pertencem ao FluentMap e nao foram corrigidos internamente. +- Assembly scanning nao e seguro sob trimming por contrato; a Entrega 3 pode substituir esse caminho por metadata gerada. +- `PropertyInfo` e `MemberInfo` continuam parte do contrato publico e da integracao com Dapper. +- O projeto smoke usa referencia direta ao assembly compilado do core durante publish trimmed/AOT para evitar propagacao de `PublishTrimmed`/`PublishAot` ao projeto `netstandard2.0`; os comandos de publish devem ser precedidos por build do core em `Release`. diff --git a/docs/sdd/etapa-4/decisions.md b/docs/sdd/etapa-4/decisions.md index 54edbf5..f90b4f0 100644 --- a/docs/sdd/etapa-4/decisions.md +++ b/docs/sdd/etapa-4/decisions.md @@ -15,6 +15,14 @@ Registre aqui apenas decisoes que afetem entregas posteriores. ## Trimming E Native AOT - A Entrega 02 deve considerar que o analyzer ja identifica alguns usos estaticamente invalidos de `AddMap()`, mas isso nao remove a divida de reflection documentada na Etapa 3. +- Registro explicito por `AddMap()` e o caminho recomendado para consumidores com IL trimming e Native AOT; ele nao emite warnings FluentMap-owned no smoke trimmed depois desta entrega. +- Assembly scanning permanece reflection-dependent por design e foi marcado com `RequiresUnreferencedCode` em `AddMapsFromAssembly(...)`, `AddMapsFromAssemblyContaining()`, `ForEntitiesInAssembly(...)`, `ForEntitiesInCurrentAssembly(...)` e `ApplyMapsFromAssemblies(...)`. +- O registry nao cria mais type maps por `Activator.CreateInstance(typeof(FluentMapTypeMap<>).MakeGenericType(type))`; um type map interno nao generico remove esse ponto de reflection dinamica sem remover a classe publica `FluentMapTypeMap`. +- `DynamicallyAccessedMembers` foi aplicado somente a fluxos verificaveis: interfaces do tipo de map em `AddMap()`, propriedades publicas em `ForEntity()`, e propriedades/construtores publicos em `Explain()`. +- Warnings restantes no smoke trimmed explicito pertencem ao Dapper (`DefaultTypeMap`, `CustomPropertyTypeMap`, `DapperRow` e helpers internos); nao devem ser corrigidos copiando ou alterando codigo do Dapper dentro do FluentMap. +- Native AOT runtime nao foi validado nesta entrega porque o ambiente Windows nao possui o platform linker C++ exigido pelo SDK. +- Metadata candidata para Source Generator na Entrega 03: entidade alvo de `AddMap()`, caminhos `Map(...)`, colunas `ToColumn(...)`, `Ignore()`, `IncludeBase()`, naming policies estaticas e instalacao de type maps sem discovery por assembly. +- O Source Generator nao deve tentar tornar `AddMapsFromAssembly(...)` AOT-safe; ele deve oferecer um caminho gerado/explicito que substitua scanning quando o consumidor desejar publicacao trimmed/AOT. ## Source Generator diff --git a/docs/sdd/etapa-4/status.md b/docs/sdd/etapa-4/status.md index c865632..e1745d4 100644 --- a/docs/sdd/etapa-4/status.md +++ b/docs/sdd/etapa-4/status.md @@ -1,5 +1,5 @@ | Entrega | Status | Commit | |---|---|---| | 01 - Roslyn Analyzers | Concluido | - | -| 02 - Trimming e Native AOT | Pendente | - | +| 02 - Trimming e Native AOT | Concluido | - | | 03 - Source Generator | Pendente | - | diff --git a/src/Dapper.FluentMap/Compatibility/CodeAnalysisAttributes.cs b/src/Dapper.FluentMap/Compatibility/CodeAnalysisAttributes.cs new file mode 100644 index 0000000..cda0369 --- /dev/null +++ b/src/Dapper.FluentMap/Compatibility/CodeAnalysisAttributes.cs @@ -0,0 +1,61 @@ +using System; + +#if !NET5_0_OR_GREATER +namespace System.Diagnostics.CodeAnalysis +{ + [AttributeUsage( + AttributeTargets.Field | + AttributeTargets.ReturnValue | + AttributeTargets.GenericParameter | + AttributeTargets.Parameter | + AttributeTargets.Property, + Inherited = false)] + internal sealed class DynamicallyAccessedMembersAttribute : Attribute + { + public DynamicallyAccessedMembersAttribute(DynamicallyAccessedMemberTypes memberTypes) + { + MemberTypes = memberTypes; + } + + public DynamicallyAccessedMemberTypes MemberTypes { get; } + } + + [Flags] + internal enum DynamicallyAccessedMemberTypes + { + None = 0, + PublicParameterlessConstructor = 0x0001, + PublicConstructors = 0x0002 | PublicParameterlessConstructor, + NonPublicConstructors = 0x0004, + PublicMethods = 0x0008, + NonPublicMethods = 0x0010, + PublicFields = 0x0020, + NonPublicFields = 0x0040, + PublicNestedTypes = 0x0080, + NonPublicNestedTypes = 0x0100, + PublicProperties = 0x0200, + NonPublicProperties = 0x0400, + PublicEvents = 0x0800, + NonPublicEvents = 0x1000, + Interfaces = 0x2000, + All = ~None + } + + [AttributeUsage( + AttributeTargets.Constructor | + AttributeTargets.Method | + AttributeTargets.Class, + Inherited = false)] + internal sealed class RequiresUnreferencedCodeAttribute : Attribute + { + public RequiresUnreferencedCodeAttribute(string message) + { + Message = message; + } + + public string Message { get; } + + public string Url { get; set; } + } +} +#endif diff --git a/src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs b/src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs index 27b0142..1ccc42e 100644 --- a/src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs +++ b/src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs @@ -1,5 +1,6 @@ using System; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; using Dapper.FluentMap.Conventions; @@ -13,6 +14,9 @@ namespace Dapper.FluentMap.Configuration /// public class FluentConventionConfiguration { + private const string AssemblyScanningRequiresUnreferencedCodeMessage = + "Convention assembly scanning discovers entity types and properties by reflection. Register conventions with ForEntity() when publishing trimmed or Native AOT applications."; + private readonly Convention _convention; /// @@ -35,7 +39,9 @@ public FluentConventionConfiguration(Convention convention) /// /// The type of the entity. /// The current instance of . - public FluentConventionConfiguration ForEntity() + public FluentConventionConfiguration ForEntity< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] + T>() { var type = typeof(T); MapProperties(type); @@ -53,6 +59,7 @@ public FluentConventionConfiguration ForEntity() /// This parameter is optional. /// /// The current instance of . + [RequiresUnreferencedCode(AssemblyScanningRequiresUnreferencedCodeMessage)] public FluentConventionConfiguration ForEntitiesInCurrentAssembly(params string[] namespaces) { foreach (var type in Assembly.GetCallingAssembly().GetExportedTypes()) @@ -82,6 +89,7 @@ public FluentConventionConfiguration ForEntitiesInCurrentAssembly(params string[ /// This parameter is optional. /// /// The current instance of . + [RequiresUnreferencedCode(AssemblyScanningRequiresUnreferencedCodeMessage)] public FluentConventionConfiguration ForEntitiesInAssembly(Assembly assembly, params string[] namespaces) { foreach (var type in assembly.GetExportedTypes()) @@ -101,7 +109,9 @@ public FluentConventionConfiguration ForEntitiesInAssembly(Assembly assembly, pa return this; } - private void MapProperties(Type type) + private void MapProperties( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] + Type type) { var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); diff --git a/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs b/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs index e1e74d6..7f46622 100644 --- a/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs +++ b/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; using Dapper.FluentMap.Conventions; @@ -14,6 +15,9 @@ namespace Dapper.FluentMap.Configuration /// public class FluentMapConfiguration { + private const string AssemblyScanningRequiresUnreferencedCodeMessage = + "Assembly scanning discovers entity maps by reflection. Register maps explicitly with AddMap() when publishing trimmed or Native AOT applications."; + /// /// Adds the specified to the configuration of Dapper.FluentMap. /// @@ -37,12 +41,14 @@ public void AddMap(IEntityMap mapper) where TEntity : class /// /// The type of the entity map to create and register. /// The current instance of . - public FluentMapConfiguration AddMap() + public FluentMapConfiguration AddMap< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] + TMap>() where TMap : IEntityMap, new() { var mapType = typeof(TMap); var entityType = GetMappedEntityType(mapType); - var mapper = CreateEntityMap(mapType); + var mapper = CreateEntityMap(); FluentMapper.Registry.AddEntityMap(entityType, mapper); return this; @@ -54,6 +60,7 @@ public FluentMapConfiguration AddMap() /// The assembly to scan for entity maps. /// Optional namespaces used to filter discovered entity map types. /// The current instance of . + [RequiresUnreferencedCode(AssemblyScanningRequiresUnreferencedCodeMessage)] public FluentMapConfiguration AddMapsFromAssembly(Assembly assembly, params string[] namespaces) { if (assembly == null) @@ -86,6 +93,7 @@ public FluentMapConfiguration AddMapsFromAssembly(Assembly assembly, params stri /// A marker type from the assembly to scan. /// Optional namespaces used to filter discovered entity map types. /// The current instance of . + [RequiresUnreferencedCode(AssemblyScanningRequiresUnreferencedCodeMessage)] public FluentMapConfiguration AddMapsFromAssemblyContaining(params string[] namespaces) { return AddMapsFromAssembly(typeof(TMarker).GetTypeInfo().Assembly, namespaces); @@ -142,6 +150,7 @@ public FluentConventionConfiguration UseNamingPolicy(Func transf return UseNamingPolicy(NamingPolicy.Custom(transformer), caseSensitive); } + [RequiresUnreferencedCode(AssemblyScanningRequiresUnreferencedCodeMessage)] private static IEnumerable FindEntityMapDefinitions(Assembly assembly, string[] namespaces) { return GetExportedTypes(assembly) @@ -152,6 +161,7 @@ private static IEnumerable FindEntityMapDefinitions(Assembl .Select(type => new EntityMapDefinition(type, GetMappedEntityType(type))); } + [RequiresUnreferencedCode(AssemblyScanningRequiresUnreferencedCodeMessage)] private static IEnumerable GetExportedTypes(Assembly assembly) { try @@ -182,7 +192,9 @@ private static bool IsNamespaceMatch(Type type, string[] namespaces) namespaces.Any(ns => string.Equals(ns, type.Namespace, StringComparison.Ordinal)); } - private static Type GetMappedEntityType(Type mapType) + private static Type GetMappedEntityType( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] + Type mapType) { var entityMapInterfaces = mapType.GetInterfaces() .Where(type => type.GetTypeInfo().IsGenericType && @@ -205,6 +217,7 @@ private static Type GetMappedEntityType(Type mapType) return entityType; } + [RequiresUnreferencedCode(AssemblyScanningRequiresUnreferencedCodeMessage)] private static IEntityMap CreateEntityMap(Type mapType) { try @@ -219,6 +232,21 @@ private static IEntityMap CreateEntityMap(Type mapType) } } + private static IEntityMap CreateEntityMap() + where TMap : IEntityMap, new() + { + try + { + return new TMap(); + } + catch (Exception ex) + { + throw new FluentMapConfigurationException( + $"Entity map type '{typeof(TMap).FullName}' could not be created. Ensure it has a public parameterless constructor and the constructor completes successfully.", + ex); + } + } + private static void EnsureNoDuplicateEntityMaps(IList definitions) { var duplicates = definitions diff --git a/src/Dapper.FluentMap/FluentMapper.cs b/src/Dapper.FluentMap/FluentMapper.cs index 82390e9..3d5a006 100644 --- a/src/Dapper.FluentMap/FluentMapper.cs +++ b/src/Dapper.FluentMap/FluentMapper.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using Dapper.FluentMap.Configuration; using Dapper.FluentMap.Conventions; using Dapper.FluentMap.Diagnostics; @@ -13,6 +14,10 @@ namespace Dapper.FluentMap /// public static class FluentMapper { + private const DynamicallyAccessedMemberTypes EntityMemberTypes = + DynamicallyAccessedMemberTypes.PublicConstructors | + DynamicallyAccessedMemberTypes.PublicProperties; + private static readonly MappingRegistry _registry = new MappingRegistry(); private static readonly FluentMapConfiguration _configuration = new FluentMapConfiguration(); @@ -54,7 +59,9 @@ public static void Validate() /// /// The entity type to explain. /// A structured explanation of configured mappings, conventions and fallback mappings. - public static MappingExplanation Explain() + public static MappingExplanation Explain< + [DynamicallyAccessedMembers(EntityMemberTypes)] + TEntity>() { return _registry.Explain(typeof(TEntity)); } diff --git a/src/Dapper.FluentMap/Mapping/EntityMap.cs b/src/Dapper.FluentMap/Mapping/EntityMap.cs index cad7f6d..15cbfdc 100644 --- a/src/Dapper.FluentMap/Mapping/EntityMap.cs +++ b/src/Dapper.FluentMap/Mapping/EntityMap.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Linq.Expressions; using System.Reflection; @@ -23,7 +24,9 @@ public interface IEntityMap /// This serves as a marker interface for generic type inference. /// /// The type of the entity to configure the mapping for. - public interface IEntityMap : IEntityMap + public interface IEntityMap< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties)] + TEntity> : IEntityMap { } @@ -37,7 +40,10 @@ internal interface IEntityMapWithIncludedBaseTypes /// /// The type of the entity. /// The type of the property mapping. - public abstract class EntityMapBase : IEntityMap, IEntityMapWithIncludedBaseTypes + public abstract class EntityMapBase< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties)] + TEntity, + TPropertyMap> : IEntityMap, IEntityMapWithIncludedBaseTypes where TPropertyMap : IPropertyMap { /// @@ -127,7 +133,9 @@ private void ThrowIfDuplicateMapping(IPropertyMap map) /// Represents a typed mapping of an entity. /// /// The type of the entity to configure the mapping for. - public abstract class EntityMap : EntityMapBase + public abstract class EntityMap< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties)] + TEntity> : EntityMapBase where TEntity : class { /// diff --git a/src/Dapper.FluentMap/MappingRegistry.cs b/src/Dapper.FluentMap/MappingRegistry.cs index fc7e0fd..ebc7f48 100644 --- a/src/Dapper.FluentMap/MappingRegistry.cs +++ b/src/Dapper.FluentMap/MappingRegistry.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; using System.Text; @@ -184,7 +185,9 @@ internal void ValidateConfiguration() throw new FluentMapConfigurationException(message.ToString()); } - internal MappingExplanation Explain(Type type) + internal MappingExplanation Explain( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties)] + Type type) { if (type == null) { @@ -246,7 +249,7 @@ internal void Reset(params Type[] dapperTypes) private void SetDapperTypeMap(Type type) { - var instance = (SqlMapper.ITypeMap)Activator.CreateInstance(typeof(FluentMapTypeMap<>).MakeGenericType(type)); + var instance = new FluentMapTypeMap(type); SqlMapper.SetTypeMap(type, instance); } @@ -392,7 +395,11 @@ private IEnumerable GetConventionPropertyMapDescrip } } - private void AddDapperDefaultExplanations(Type type, IList members, IList configuredPaths) + private void AddDapperDefaultExplanations( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties)] + Type type, + IList members, + IList configuredPaths) { foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance) .Where(p => p.GetIndexParameters().Length == 0)) @@ -419,6 +426,7 @@ private void AddDapperDefaultExplanations(Type type, IList members, IList configuredPaths, @@ -442,7 +450,10 @@ private void AddMemberExplanation( configuredPaths.Add(memberPath); } - private static IEnumerable GetConstructorParameters(Type entityType, PropertyInfo property) + private static IEnumerable GetConstructorParameters( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] + Type entityType, + PropertyInfo property) { foreach (var constructor in entityType.GetConstructors(BindingFlags.Public | BindingFlags.Instance)) { diff --git a/src/Dapper.FluentMap/TypeMaps/FluentMapTypeMap.cs b/src/Dapper.FluentMap/TypeMaps/FluentMapTypeMap.cs new file mode 100644 index 0000000..649d34f --- /dev/null +++ b/src/Dapper.FluentMap/TypeMaps/FluentMapTypeMap.cs @@ -0,0 +1,27 @@ +using System; +using System.Reflection; +using Dapper.FluentMap.Mapping; + +namespace Dapper.FluentMap.TypeMaps +{ + internal sealed class FluentMapTypeMap : MultiTypeMap + { + internal FluentMapTypeMap(Type entityType) + : base( + new FluentConstructorTypeMap(entityType, GetPropertyMap), + new CustomPropertyTypeMap(entityType, GetPropertyInfo), + new DefaultTypeMap(entityType)) + { + } + + private static IPropertyMap GetPropertyMap(Type type, string columnName) + { + return FluentMapper.Registry.GetFluentPropertyMap(type, columnName); + } + + private static PropertyInfo GetPropertyInfo(Type type, string columnName) + { + return FluentMapper.Registry.GetFluentPropertyInfo(type, columnName); + } + } +} diff --git a/src/Dapper.FluentMap/Utils/FluentMapConfigurationExtensions.cs b/src/Dapper.FluentMap/Utils/FluentMapConfigurationExtensions.cs index 4349cdf..51d13be 100644 --- a/src/Dapper.FluentMap/Utils/FluentMapConfigurationExtensions.cs +++ b/src/Dapper.FluentMap/Utils/FluentMapConfigurationExtensions.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; using Dapper.FluentMap.Configuration; @@ -12,6 +13,9 @@ namespace Dapper.FluentMap.Utils /// public static class FluentMapConfigurationExtensions { + private const string AssemblyScanningRequiresUnreferencedCodeMessage = + "Assembly scanning discovers entity maps by reflection. Register maps explicitly with AddMap() when publishing trimmed or Native AOT applications."; + /// /// Finds all types, from provided assemblies, implementing /// and applies them to , @@ -19,6 +23,7 @@ public static class FluentMapConfigurationExtensions /// /// The instance. /// The assemblies to scan for entity maps. + [RequiresUnreferencedCode(AssemblyScanningRequiresUnreferencedCodeMessage)] public static void ApplyMapsFromAssemblies(this FluentMapConfiguration configuration, params Assembly[] assemblies) { if (assemblies == null) diff --git a/test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj b/test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj new file mode 100644 index 0000000..139bc7b --- /dev/null +++ b/test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj @@ -0,0 +1,16 @@ + + + Exe + net10.0 + false + enable + enable + + + + + ..\..\src\Dapper.FluentMap\bin\$(Configuration)\netstandard2.0\Dapper.FluentMap.dll + + + + diff --git a/test/Dapper.FluentMap.AotSmoke/Program.cs b/test/Dapper.FluentMap.AotSmoke/Program.cs new file mode 100644 index 0000000..4f2be47 --- /dev/null +++ b/test/Dapper.FluentMap.AotSmoke/Program.cs @@ -0,0 +1,112 @@ +using System; +using System.Linq; +using Dapper; +using Dapper.FluentMap; +using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.Naming; + +#if AOT_SMOKE_SCANNING +const string scenario = "scanning"; +FluentMapper.Initialize(configuration => configuration.AddMapsFromAssemblyContaining()); + +AssertMappedMember("customer_id", nameof(Customer.Id)); +#else +const string scenario = "explicit"; +FluentMapper.Initialize(configuration => +{ + configuration.AddMap(); + configuration.AddMap(); + configuration.UseNamingPolicy(NamingPolicy.SnakeCase).ForEntity(); +}); + +AssertMappedMember("customer_id", nameof(Customer.Id)); +AssertMappedMember("created_at", nameof(NamingCustomer.CreatedAt)); +AssertConstructorMapping(); +AssertExplain(); +#endif + +Console.WriteLine(scenario + ":ok"); + +static void AssertMappedMember(string columnName, string propertyName) +{ + var member = SqlMapper.GetTypeMap(typeof(TEntity)).GetMember(columnName); + if (member?.Property?.Name != propertyName) + { + throw new InvalidOperationException( + $"Column '{columnName}' was not mapped to property '{propertyName}'."); + } +} + +#if !AOT_SMOKE_SCANNING +static void AssertConstructorMapping() +{ + var typeMap = SqlMapper.GetTypeMap(typeof(ImmutableCustomer)); + var constructor = typeMap.FindConstructor( + new[] { "customer_id", "name" }, + new[] { typeof(int), typeof(string) }); + + if (constructor == null) + { + throw new InvalidOperationException("Constructor mapping was not resolved."); + } + + var member = typeMap.GetConstructorParameter(constructor, "customer_id"); + if (member?.Parameter?.Name != "id") + { + throw new InvalidOperationException("Constructor parameter mapping was not resolved."); + } +} + +static void AssertExplain() +{ + var explanation = FluentMapper.Explain(); + if (!explanation.Members.Any(member => + member.MemberPath == nameof(Customer.Id) && + member.ColumnName == "customer_id")) + { + throw new InvalidOperationException("Explain did not include the explicit mapping."); + } +} +#endif + +public sealed class Customer +{ + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + } +} + +public sealed class NamingCustomer +{ + public DateTime CreatedAt { get; set; } +} + +public sealed class ImmutableCustomer +{ + public ImmutableCustomer(int id, string name) + { + Id = id; + Name = name; + } + + public int Id { get; } + + public string Name { get; } +} + +public sealed class ImmutableCustomerMap : EntityMap +{ + public ImmutableCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Name).ToColumn("name"); + } +} diff --git a/test/Dapper.FluentMap.Tests/ManualMappingTests.cs b/test/Dapper.FluentMap.Tests/ManualMappingTests.cs index 3869027..5bedc22 100644 --- a/test/Dapper.FluentMap.Tests/ManualMappingTests.cs +++ b/test/Dapper.FluentMap.Tests/ManualMappingTests.cs @@ -1,7 +1,6 @@ using System; using System.Linq; using Dapper.FluentMap.Mapping; -using Dapper.FluentMap.TypeMaps; using Xunit; [assembly: CollectionBehavior(DisableTestParallelization = true)] @@ -118,7 +117,8 @@ public void FluentMapperInitializeShouldAddDapperTypeMap() // Assert Assert.NotNull(typeMap); - Assert.IsType>(typeMap); + var member = typeMap.GetMember("test"); + Assert.Equal(typeof(TestEntity).GetProperty(nameof(TestEntity.Id)), member.Property); } [Fact] diff --git a/test/Dapper.FluentMap.Tests/MappingRegistrationTests.cs b/test/Dapper.FluentMap.Tests/MappingRegistrationTests.cs index 1267ce1..1c9be9b 100644 --- a/test/Dapper.FluentMap.Tests/MappingRegistrationTests.cs +++ b/test/Dapper.FluentMap.Tests/MappingRegistrationTests.cs @@ -4,7 +4,6 @@ using System.Reflection; using Dapper; using Dapper.FluentMap.Mapping; -using Dapper.FluentMap.TypeMaps; using Microsoft.Data.Sqlite; using Xunit; @@ -41,7 +40,9 @@ public void GenericRegistrationShouldAddEntityMapAndDapperTypeMap() FluentMapper.Initialize(c => c.AddMap()); Assert.IsType(FluentMapper.EntityMaps[typeof(GenericRegistrationEntity)]); - Assert.IsType>(SqlMapper.GetTypeMap(typeof(GenericRegistrationEntity))); + var typeMap = SqlMapper.GetTypeMap(typeof(GenericRegistrationEntity)); + var member = typeMap.GetMember("generic_id"); + Assert.Equal(typeof(GenericRegistrationEntity).GetProperty(nameof(GenericRegistrationEntity.Id)), member.Property); var property = FluentMapper.Registry.GetFluentPropertyInfo(typeof(GenericRegistrationEntity), "generic_id"); Assert.Equal(typeof(GenericRegistrationEntity).GetProperty(nameof(GenericRegistrationEntity.Id)), property); From ee2391af52e16fd053dfaadd039ee50ec88ad78c Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sun, 26 Jul 2026 12:38:33 -0300 Subject: [PATCH 10/20] feat: add mapping registration source generator --- Dapper.FluentMap.sln | 21 + README.md | 24 + docs/sdd/etapa-4/03-source-generator.md | 396 ++++++++++++++ docs/sdd/etapa-4/README.md | 23 + docs/sdd/etapa-4/decisions.md | 10 + docs/sdd/etapa-4/status.md | 6 +- .../AnalyzerReleases.Shipped.md | 10 + .../AnalyzerReleases.Unshipped.md | 9 + .../Dapper.FluentMap.Generators.csproj | 26 + .../MappingRegistrationGenerator.cs | 404 ++++++++++++++ src/Dapper.FluentMap.Generators/README.md | 5 + .../Properties/AssemblyInfo.cs | 1 + .../Dapper.FluentMap.AotSmoke.csproj | 4 + test/Dapper.FluentMap.AotSmoke/Program.cs | 14 +- .../AssemblyInfo.cs | 3 + ...uentMap.GeneratedRegistration.Tests.csproj | 18 + .../GeneratedRegistrationIntegrationTests.cs | 155 ++++++ .../Dapper.FluentMap.Generators.Tests.csproj | 17 + .../MappingRegistrationGeneratorTests.cs | 495 ++++++++++++++++++ 19 files changed, 1637 insertions(+), 4 deletions(-) create mode 100644 docs/sdd/etapa-4/03-source-generator.md create mode 100644 src/Dapper.FluentMap.Generators/AnalyzerReleases.Shipped.md create mode 100644 src/Dapper.FluentMap.Generators/AnalyzerReleases.Unshipped.md create mode 100644 src/Dapper.FluentMap.Generators/Dapper.FluentMap.Generators.csproj create mode 100644 src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs create mode 100644 src/Dapper.FluentMap.Generators/README.md create mode 100644 test/Dapper.FluentMap.GeneratedRegistration.Tests/AssemblyInfo.cs create mode 100644 test/Dapper.FluentMap.GeneratedRegistration.Tests/Dapper.FluentMap.GeneratedRegistration.Tests.csproj create mode 100644 test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs create mode 100644 test/Dapper.FluentMap.Generators.Tests/Dapper.FluentMap.Generators.Tests.csproj create mode 100644 test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs diff --git a/Dapper.FluentMap.sln b/Dapper.FluentMap.sln index e44aef5..915e4e9 100644 --- a/Dapper.FluentMap.sln +++ b/Dapper.FluentMap.sln @@ -25,6 +25,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapper.FluentMap.Analyzers. EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dapper.FluentMap.AotSmoke", "test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj", "{2E23213D-A547-4FF6-BB58-8793860C18FE}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapper.FluentMap.Generators", "src\Dapper.FluentMap.Generators\Dapper.FluentMap.Generators.csproj", "{25768DB1-489F-4544-BDD4-8B0D0E88C6E5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapper.FluentMap.Generators.Tests", "test\Dapper.FluentMap.Generators.Tests\Dapper.FluentMap.Generators.Tests.csproj", "{BA72BEA0-BB6E-41EE-ABFE-215FF0A1E9BB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapper.FluentMap.GeneratedRegistration.Tests", "test\Dapper.FluentMap.GeneratedRegistration.Tests\Dapper.FluentMap.GeneratedRegistration.Tests.csproj", "{87E09F49-F805-44EB-BA59-87C93C68497D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -59,6 +65,18 @@ Global {2E23213D-A547-4FF6-BB58-8793860C18FE}.Debug|Any CPU.Build.0 = Debug|Any CPU {2E23213D-A547-4FF6-BB58-8793860C18FE}.Release|Any CPU.ActiveCfg = Release|Any CPU {2E23213D-A547-4FF6-BB58-8793860C18FE}.Release|Any CPU.Build.0 = Release|Any CPU + {25768DB1-489F-4544-BDD4-8B0D0E88C6E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {25768DB1-489F-4544-BDD4-8B0D0E88C6E5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {25768DB1-489F-4544-BDD4-8B0D0E88C6E5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {25768DB1-489F-4544-BDD4-8B0D0E88C6E5}.Release|Any CPU.Build.0 = Release|Any CPU + {BA72BEA0-BB6E-41EE-ABFE-215FF0A1E9BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BA72BEA0-BB6E-41EE-ABFE-215FF0A1E9BB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BA72BEA0-BB6E-41EE-ABFE-215FF0A1E9BB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BA72BEA0-BB6E-41EE-ABFE-215FF0A1E9BB}.Release|Any CPU.Build.0 = Release|Any CPU + {87E09F49-F805-44EB-BA59-87C93C68497D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {87E09F49-F805-44EB-BA59-87C93C68497D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {87E09F49-F805-44EB-BA59-87C93C68497D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {87E09F49-F805-44EB-BA59-87C93C68497D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -71,6 +89,9 @@ Global {424B90AD-406E-4CC1-B0F4-917F47A06E4D} = {580E3446-6579-4414-9875-970849E635E5} {F5059D11-D45B-4793-B6E0-7758F57AC0E1} = {742442F2-CAE7-4DC8-BD73-8C54C0005A53} {2E23213D-A547-4FF6-BB58-8793860C18FE} = {742442F2-CAE7-4DC8-BD73-8C54C0005A53} + {25768DB1-489F-4544-BDD4-8B0D0E88C6E5} = {580E3446-6579-4414-9875-970849E635E5} + {BA72BEA0-BB6E-41EE-ABFE-215FF0A1E9BB} = {742442F2-CAE7-4DC8-BD73-8C54C0005A53} + {87E09F49-F805-44EB-BA59-87C93C68497D} = {742442F2-CAE7-4DC8-BD73-8C54C0005A53} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {10834736-59FD-47FF-9344-096247DC48CD} diff --git a/README.md b/README.md index 871ee40..38d7ece 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,20 @@ FluentMapper.Initialize(config => Assembly scanning APIs such as `AddMapsFromAssembly(...)`, `AddMapsFromAssemblyContaining()`, `ForEntitiesInAssembly(...)`, `ForEntitiesInCurrentAssembly(...)` and the legacy `ApplyMapsFromAssemblies(...)` depend on reflection discovery and are annotated as trimming-sensitive. They remain supported for normal runtime usage, but they can warn or fail after trimming if discovered types or metadata are removed. +#### Generated mapping registration +Consumers can opt into `Dapper.FluentMap.Generators` to generate explicit registration for maps declared in the current project: + +```csharp +using Dapper.FluentMap; + +FluentMapper.Initialize(config => + { + config.AddGeneratedMappings(); + }); +``` + +The generated method calls `AddMap()` for each eligible map in the current compilation. It does not scan referenced assemblies, instantiate maps during generation, or generate materializers. + #### Convention based mapping When you have a lot of entity types, creating manual mapping classes can become plumbing. If your column names adhere to some kind of naming convention, you might be better off by configuring a mapping convention. @@ -206,3 +220,13 @@ FluentMapper.Initialize(config => - Naming policies implementadas: `SnakeCase`, `Prefix`, `Suffix`, `Custom` e composicao por `Then`, `WithPrefix` e `WithSuffix`, sem alterar `DefaultTypeMap.MatchNamesWithUnderscores`. - Dividas adiadas: nested object materialization, Value Objects complexos, constructor/record mapping, multiple mapping profiles, Roslyn analyzers, source generators e AOT/trimming. - Relatorios: `docs/sdd/etapa-2/01-member-path.md`, `docs/sdd/etapa-2/02-configuration-validation.md`, `docs/sdd/etapa-2/03-inherited-mappings.md`, `docs/sdd/etapa-2/04-naming-policies.md`. + +## Resultado da Etapa 4 + +- Tooling disponivel: `Dapper.FluentMap.Analyzers` com diagnostics `DFM001` a `DFM005` e `Dapper.FluentMap.Generators` com `AddGeneratedMappings()`, `DFM006` e `DFM007`. +- Trimming: registro explicito e registro gerado foram validados em smoke trimmed sem warnings FluentMap-owned; assembly scanning permanece reflection-dependent e trimming-sensitive. +- Native AOT: publish continua bloqueado neste ambiente por ausencia do platform linker C++; nao ha declaracao de runtime AOT completo. +- Caminhos de registro: manual, gerado e assembly scanning coexistem; nenhum caminho antigo foi removido. +- Packaging: analyzer e generator ficam em `analyzers/dotnet/cs`; o core continua `netstandard2.0` sem dependencias Roslyn runtime. +- Limitacoes: o generator descobre apenas maps da compilacao atual e nao resolve nested object materialization, Value Objects complexos, multiple mapping profiles, query-specific mappings, custom materializer ou generated `DbDataReader` materializer. +- Relatorios: `docs/sdd/etapa-4/01-roslyn-analyzers.md`, `docs/sdd/etapa-4/02-trimming-aot.md`, `docs/sdd/etapa-4/03-source-generator.md`. diff --git a/docs/sdd/etapa-4/03-source-generator.md b/docs/sdd/etapa-4/03-source-generator.md new file mode 100644 index 0000000..199a634 --- /dev/null +++ b/docs/sdd/etapa-4/03-source-generator.md @@ -0,0 +1,396 @@ +# 03 - Source Generator + +## Specification + +Esta entrega avalia e implementa um Source Generator incremental para reduzir reflection no registro de mappings, sem substituir o runtime nem remover caminhos existentes. + +Objetivos tratados: + +- descobrir em compile-time classes de mapping declaradas na compilacao atual; +- gerar chamadas explicitas para `FluentMapConfiguration.AddMap()`; +- evitar `Assembly.GetTypes`, `Assembly.GetExportedTypes` e `Activator.CreateInstance(Type)` no caminho gerado; +- preservar registro manual e assembly scanning; +- manter o core `Dapper.FluentMap` sem dependencia Roslyn; +- validar o caminho gerado com Dapper, naming policies, inheritance e constructor mapping; +- documentar limites reais de trimming e Native AOT sem declarar suporte nao validado. + +Fora do objetivo: + +- materializador gerado; +- leitura gerada de `DbDataReader`; +- SQL, CRUD, query wrappers ou ORM; +- nested object construction; +- converters; +- suporte automatico a todo o grafo de assemblies referenciados. + +Experiencia desejada: + +```csharp +using Dapper.FluentMap; + +FluentMapper.Initialize(configuration => +{ + configuration.AddGeneratedMappings(); +}); +``` + +Codigo gerado conceitual: + +```csharp +configuration + .AddMap() + .AddMap(); +``` + +O generator e opcional. A biblioteca continua funcional sem ele. + +## Discovery + +Arquivos e contexto analisados: + +- `AGENTS.md` +- `.agents/skills/run-tests/SKILL.md` +- `.agents/skills/msbuild-modernization/SKILL.md` +- `.agents/skills/msbuild-antipatterns/SKILL.md` +- `.agents/skills/dotnet-aot-compat/SKILL.md` +- `.agents/skills/msbuild-antipatterns/references/private-assets.md` +- `docs/sdd/etapa-1/README.md` +- `docs/sdd/etapa-1/decisions.md` +- `docs/sdd/etapa-2/README.md` +- `docs/sdd/etapa-2/decisions.md` +- `docs/sdd/etapa-3/README.md` +- `docs/sdd/etapa-3/decisions.md` +- `docs/sdd/etapa-3/01-mapping-registration.md` +- `docs/sdd/etapa-4/README.md` +- `docs/sdd/etapa-4/status.md` +- `docs/sdd/etapa-4/decisions.md` +- `docs/sdd/etapa-4/01-roslyn-analyzers.md` +- `docs/sdd/etapa-4/02-trimming-aot.md` +- `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs` +- `src/Dapper.FluentMap/Mapping/EntityMap.cs` +- `src/Dapper.FluentMap/MappingRegistry.cs` +- `src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs` +- projetos e testes existentes. + +Entregas anteriores da Etapa 4: + +- Entrega 01 - Roslyn Analyzers: `Concluido`, commit local `9075f61`. +- Entrega 02 - Trimming e Native AOT: `Concluido`, commit local `d559d65`. + +Respostas de discovery: + +1. Como identificar um mapping em compile-time: + - um `class` symbol declarado na compilacao atual que implemente uma interface fechada `Dapper.FluentMap.Mapping.IEntityMap`. +2. Tipo base/interface que representa mapping: + - `IEntityMap` e a interface nao generica `IEntityMap`; `EntityMap` e `EntityMapBase` sao bases comuns, mas a decisao usa a interface fechada para nao depender da hierarquia concreta. +3. Classes de mapping: + - abstratas: nao registraveis pelo caminho gerado; reportadas por `DFM006` e ignoradas; + - genericas abertas: nao registraveis pelo caminho gerado; reportadas por `DFM006` e ignoradas; + - nested: suportadas quando a classe e todos os containing types sao `public` ou `internal`; + - internal: suportadas quando possuem construtor publico sem parametros; + - private/protected/file-local: nao acessiveis pelo codigo gerado top-level; reportadas por `DFM006` e ignoradas; + - sem construtor publico sem parametros: reportadas por `DFM006` e ignoradas. +4. Como `AddMap()` funciona: + - `TMap` deve implementar `IEntityMap`, possuir `new()`, e implementar exatamente uma interface fechada `IEntityMap`; + - a entidade e inferida pelo runtime a partir das interfaces; + - a instancia e criada por `new TMap()` e registrada pelo mesmo `MappingRegistry`. +5. Constraints: + - `where TMap : IEntityMap, new()`; + - entidade alvo deve ser `class`; + - runtime ainda valida duplicidade, colunas, `IncludeBase()`, composition e instalacao do type map do Dapper. +6. Duplicidades: + - o generator detecta duas classes geraveis para a mesma entidade na compilacao atual e reporta `DFM007`; + - o runtime continua autoridade para duplicidade causada por registro manual, scanning, ordem dinamica ou assemblies externos. +7. Ativacao: + - instalacao/referencia do pacote/projeto `Dapper.FluentMap.Generators` como analyzer/source generator. +8. Assembly do codigo gerado: + - o codigo e gerado dentro do assembly do consumidor onde o generator esta executando. +9. Assemblies referenciados: + - nao sao descobertos nesta entrega. +10. Como evitar geracao duplicada: + - partial declarations sao agrupadas por nome simbolico do map; + - a saida possui hint name unico `DapperFluentMapGeneratedRegistration.g.cs`; + - mapas sao ordenados deterministicamente por profundidade de heranca da entidade, nome da entidade e nome do map. + +## Decision + +Foi criado o projeto separado: + +```text +src/Dapper.FluentMap.Generators/ +``` + +Motivos: + +- manter o core sem Roslyn; +- permitir opt-in separado do pacote runtime; +- empacotar o assembly em `analyzers/dotnet/cs`; +- evitar dependencia circular com `Dapper.FluentMap.Analyzers`. + +Nao foi criado projeto comum entre analyzer e generator. A duplicacao atual e pequena: identificacao de `IEntityMap` e descriptor `DFM005`. Um projeto comum so seria justificado quando houver compartilhamento maior e estavel. + +API gerada: + +```csharp +namespace Dapper.FluentMap +{ + internal static class DapperFluentMapGeneratedRegistration + { + public static FluentMapConfiguration AddGeneratedMappings( + this FluentMapConfiguration configuration); + } +} +``` + +Caracteristicas: + +- namespace `Dapper.FluentMap`, pois consumidores normalmente ja importam esse namespace para `FluentMapper`; +- classe `internal`, reduzindo superficie publica do assembly consumidor; +- metodo extension acessivel dentro do assembly consumidor; +- null check em `configuration`; +- retorno da propria configuracao para composicao fluente; +- chamadas fully-qualified a `AddMap()`; +- nenhum `using` fragil; +- nenhum reflection, scanning ou estado capturado no codigo gerado. + +Escopo de descoberta: + +- somente maps declarados na compilacao atual; +- nenhum traversal automatico de references. + +Motivos para nao atravessar references: + +- custo; +- determinismo; +- duplicidade entre assemblies; +- regras de visibilidade; +- risco de surpresa para o consumidor; +- possivel ambiguidade de multiplos assemblies gerando o mesmo extension method. + +Diagnostics: + +| ID | Severidade | Situacao | +|---|---|---| +| DFM005 | Error | tipo candidato implementa zero/multiplas interfaces fechadas `IEntityMap` ou entidade alvo nao e class | +| DFM006 | Info | mapping candidato nao entra na geracao por ser abstrato, generico aberto, inacessivel ou sem construtor publico sem parametros | +| DFM007 | Error | mais de um mapping geravel para a mesma entidade na compilacao atual | + +`DFM005` foi reutilizado porque a regra semantica e a mesma do analyzer de `AddMap()`: o tipo nao satisfaz o contrato de registro generico. + +Comparacao de estrategias: + +| Estrategia | Reflection | Trimming | AOT | Manutencao manual | +|---|---|---|---|---| +| Registro manual | Nao usa scanning; `AddMap()` ainda infere `IEntityMap` por metadata anotada | Smoke explicit trimmed publica e executa; 0 warnings FluentMap-owned | Publish bloqueado no ambiente por linker C++ ausente; runtime nao validado | Alta: cada map precisa ser listado | +| Registro gerado | Codigo gerado nao usa reflection; chama `AddMap()` | Smoke generated trimmed publica e executa; 0 warnings FluentMap-owned | Mesmo bloqueio de linker do ambiente; runtime nao validado | Baixa dentro do assembly atual | +| Assembly scanning | Usa `Assembly.GetExportedTypes`/`GetTypes` e `Activator.CreateInstance(Type)` | Smoke scanning trimmed emite `IL2026` FluentMap-owned e falha em runtime | Nao validado; tratado como reflection-dependent | Baixa | + +## Delivery + +Arquivos adicionados: + +- `src/Dapper.FluentMap.Generators/Dapper.FluentMap.Generators.csproj` +- `src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs` +- `src/Dapper.FluentMap.Generators/README.md` +- `src/Dapper.FluentMap.Generators/AnalyzerReleases.Shipped.md` +- `src/Dapper.FluentMap.Generators/AnalyzerReleases.Unshipped.md` +- `test/Dapper.FluentMap.Generators.Tests/Dapper.FluentMap.Generators.Tests.csproj` +- `test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs` +- `test/Dapper.FluentMap.GeneratedRegistration.Tests/Dapper.FluentMap.GeneratedRegistration.Tests.csproj` +- `test/Dapper.FluentMap.GeneratedRegistration.Tests/AssemblyInfo.cs` +- `test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs` +- `docs/sdd/etapa-4/03-source-generator.md` + +Arquivos alterados: + +- `Dapper.FluentMap.sln` +- `README.md` +- `src/Dapper.FluentMap/Properties/AssemblyInfo.cs` +- `test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj` +- `test/Dapper.FluentMap.AotSmoke/Program.cs` +- `docs/sdd/etapa-4/README.md` +- `docs/sdd/etapa-4/status.md` +- `docs/sdd/etapa-4/decisions.md` + +Implementacao: + +- generator incremental por `IIncrementalGenerator`; +- `SyntaxProvider` filtra `class` com base list e valida por semantic model; +- descoberta usa symbols, nao executa codigo do consumidor; +- maps abstratos, genericos abertos, inacessiveis ou sem construtor publico sem parametros sao ignorados com diagnostic informativo; +- duplicidade de entidade no conjunto geravel falha com diagnostic `DFM007`; +- partial declarations sao deduplicadas; +- saida deterministica por ordenacao estavel; +- codigo gerado contem header `// ` e `GeneratedCodeAttribute`; +- codigo gerado usa nomes fully-qualified; +- codigo gerado nao usa reflection. + +Testes unitarios do generator cobrem: + +- zero mappings; +- um mapping; +- varios mappings; +- mapping internal suportado; +- mapping abstrato; +- mapping generico aberto; +- duplicidade; +- namespaces distintos; +- mesmo nome de classe em namespaces diferentes; +- saida deterministica; +- recompilacao incremental em execucoes repetidas do driver; +- codigo gerado compila. + +Teste de integracao cobre: + +- `AddGeneratedMappings()` executado em projeto real com generator como analyzer; +- materializacao real via Dapper e SQLite in-memory; +- mapping internal; +- inheritance por `IncludeBase()`; +- constructor mapping; +- naming policy `SnakeCase` coexistindo com registro gerado. + +Smoke AOT: + +- o projeto `Dapper.FluentMap.AotSmoke` recebeu caminho `AOT_SMOKE_GENERATED`; +- o generator e referenciado como analyzer somente quando `DefineConstants=AOT_SMOKE_GENERATED`. + +Packaging: + +- `Dapper.FluentMap.Generators` empacota apenas `analyzers/dotnet/cs/Dapper.FluentMap.Generators.dll` e `README.md`; +- `SuppressDependenciesWhenPacking=true`; +- pacotes Roslyn usam `PrivateAssets="all"`; +- core nao referencia Roslyn; +- nenhum pacote Roslyn vira dependencia runtime do core. + +## Validation + +Ambiente: + +- SDK: `10.0.302` +- test runner detectado: VSTest com xUnit v3 +- core: `netstandard2.0` +- testes: `net10.0` +- projeto generator: `netstandard2.0` + +Validacao localizada executada: + +```text +dotnet build .\src\Dapper.FluentMap.Generators\Dapper.FluentMap.Generators.csproj +dotnet build .\test\Dapper.FluentMap.Generators.Tests\Dapper.FluentMap.Generators.Tests.csproj +dotnet test .\test\Dapper.FluentMap.Generators.Tests\Dapper.FluentMap.Generators.Tests.csproj --no-build +dotnet test .\test\Dapper.FluentMap.GeneratedRegistration.Tests\Dapper.FluentMap.GeneratedRegistration.Tests.csproj +dotnet test .\test\Dapper.FluentMap.Analyzers.Tests\Dapper.FluentMap.Analyzers.Tests.csproj --no-build +``` + +Resultados locais ja observados: + +- build do generator: sucesso, 0 warnings, 0 erros; +- build dos testes do generator: sucesso, 0 warnings, 0 erros; +- testes do generator: sucesso, 12 testes aprovados; +- testes de integracao do registro gerado: sucesso, 1 teste aprovado; +- testes do analyzer: sucesso, 7 testes aprovados. + +Validacao final completa: + +```text +dotnet restore +dotnet build +dotnet test +dotnet build --configuration Release +dotnet test --configuration Release +dotnet test --configuration Release --no-build +``` + +Resultado: + +- `dotnet restore`: sucesso; +- `dotnet build`: sucesso, 0 warnings, 0 erros; +- `dotnet test`: sucesso, 128 testes do core, 7 Dommel, 7 analyzer, 12 generator e 1 generated-registration integration; +- `dotnet build --configuration Release`: sucesso, 0 warnings, 0 erros; +- `dotnet test --configuration Release`: sucesso com os mesmos 155 testes; +- `dotnet test --configuration Release --no-build`: sucesso com os mesmos 155 testes. + +Smokes normais: + +```text +dotnet run --project .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release +dotnet run --project .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:DefineConstants=AOT_SMOKE_SCANNING +dotnet run --project .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:DefineConstants=AOT_SMOKE_GENERATED +``` + +Resultado: + +- `explicit:ok`; +- `scanning:ok`; +- `generated:ok`. + +Publish trimmed: + +```text +dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishTrimmed=true -p:TrimMode=full -p:PublishAot=false -p:TrimmerSingleWarn=false -p:ILLinkTreatWarningsAsErrors=false +.\test\Dapper.FluentMap.AotSmoke\bin\Release\net10.0\win-x64\publish\Dapper.FluentMap.AotSmoke.exe + +dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishTrimmed=true -p:TrimMode=full -p:PublishAot=false -p:DefineConstants=AOT_SMOKE_GENERATED -p:TrimmerSingleWarn=false -p:ILLinkTreatWarningsAsErrors=false +.\test\Dapper.FluentMap.AotSmoke\bin\Release\net10.0\win-x64\publish\Dapper.FluentMap.AotSmoke.exe + +dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishTrimmed=true -p:TrimMode=full -p:PublishAot=false -p:DefineConstants=AOT_SMOKE_SCANNING -p:TrimmerSingleWarn=false -p:ILLinkTreatWarningsAsErrors=false +.\test\Dapper.FluentMap.AotSmoke\bin\Release\net10.0\win-x64\publish\Dapper.FluentMap.AotSmoke.exe +``` + +Resultado: + +- explicit trimmed: publish concluido, runtime `explicit:ok`, 0 warnings FluentMap-owned; +- generated trimmed: publish concluido, runtime `generated:ok`, 0 warnings FluentMap-owned; +- scanning trimmed: publish concluido com `IL2026` FluentMap-owned esperado em `AddMapsFromAssemblyContaining()`; runtime falhou com `Column 'customer_id' was not mapped to property 'Id'.`; +- warnings restantes nos caminhos explicit/generated pertencem ao Dapper (`DefaultTypeMap`, `CustomPropertyTypeMap`, `DapperRow` e helpers internos). + +Native AOT: + +```text +dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishAot=true -p:TrimmerSingleWarn=false -p:ILLinkTreatWarningsAsErrors=false -p:MSBuildWarningsAsMessages= +dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishAot=true -p:DefineConstants=AOT_SMOKE_GENERATED -p:TrimmerSingleWarn=false -p:ILLinkTreatWarningsAsErrors=false -p:MSBuildWarningsAsMessages= +``` + +Resultado: + +- explicit AOT: falhou com `Platform linker not found`; +- generated AOT: falhou com `Platform linker not found`; +- runtime Native AOT nao foi validado neste ambiente. + +Pack e inspecao: + +```text +dotnet pack .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release --no-build --output .\artifacts\packages +dotnet pack .\src\Dapper.FluentMap.Generators\Dapper.FluentMap.Generators.csproj --configuration Release --no-build --output .\artifacts\packages +dotnet pack .\src\Dapper.FluentMap.Analyzers\Dapper.FluentMap.Analyzers.csproj --configuration Release --no-build --output .\artifacts\packages +dotnet list .\src\Dapper.FluentMap\Dapper.FluentMap.csproj package --include-transitive +dotnet list .\src\Dapper.FluentMap.Generators\Dapper.FluentMap.Generators.csproj package --include-transitive +``` + +Resultado: + +- `Dapper.FluentMap.2.0.0.nupkg` criado; warning existente `NU5125` sobre `PackageLicenseUrl`; +- `Dapper.FluentMap.Generators.2.0.0.nupkg` criado; +- `Dapper.FluentMap.Analyzers.2.0.0.nupkg` criado; +- pacote generator contem `README.md` e `analyzers/dotnet/cs/Dapper.FluentMap.Generators.dll`, sem `lib/`; +- nuspec do generator nao contem grupo de dependencias; +- pacote analyzer continua contendo apenas `README.md` e `analyzers/dotnet/cs/Dapper.FluentMap.Analyzers.dll`, sem `lib/`; +- pacote core contem apenas `lib/netstandard2.0/Dapper.FluentMap.dll` e XML docs; +- core continua com `Dapper` como unica dependencia direta e nenhuma dependencia Roslyn. + +Limitacoes restantes: + +- mappings em assemblies referenciados nao sao descobertos automaticamente; +- o extension method gerado e `internal`, portanto o assembly que declara os maps deve chamar seu proprio `AddGeneratedMappings()`; +- abstract maps e open generic maps sao ignorados em vez de registrados; +- ordem por inheritance depth cobre o caso esperado de base maps antes de derived maps, mas o runtime continua autoridade para `IncludeBase()` dinamico; +- Native AOT runtime continua bloqueado neste ambiente pela ausencia do platform linker C++. + +Dividas explicitamente fora desta etapa: + +- Nested object materialization; +- Value Objects complexos; +- Multiple mapping profiles; +- Query-specific mappings; +- Custom materializer; +- Generated DbDataReader materializer. diff --git a/docs/sdd/etapa-4/README.md b/docs/sdd/etapa-4/README.md index cb90700..15eaa79 100644 --- a/docs/sdd/etapa-4/README.md +++ b/docs/sdd/etapa-4/README.md @@ -54,3 +54,26 @@ Regra principal: ```text Se nao for possivel provar estaticamente, nao reporte como erro. ``` + +## Resultado da Etapa 4 + +A Etapa 4 adicionou tooling build-time e validacao de publicacao sem alterar o contrato runtime principal do `Dapper.FluentMap`. + +Resumo: + +- analyzers Roslyn em `Dapper.FluentMap.Analyzers`, com diagnostics `DFM001` a `DFM005`; +- generator incremental em `Dapper.FluentMap.Generators`, com registro gerado por `AddGeneratedMappings()`; +- diagnostics novos do generator: `DFM006` para mapping candidato ignorado e `DFM007` para duplicidade geravel de entidade; +- core preservado em `netstandard2.0` e sem dependencia Roslyn; +- registro manual e `AddMap()` permanecem suportados; +- registro gerado complementa o caminho explicito para evitar assembly scanning; +- assembly scanning permanece suportado como conveniencia reflection-dependent e trimming-sensitive; +- caminho explicito e caminho gerado nao emitiram warnings FluentMap-owned nos smokes trimmed executados; +- Native AOT runtime nao foi validado no ambiente local porque faltou o platform linker C++ exigido pelo SDK; +- pacotes de analyzer/generator sao empacotados em `analyzers/dotnet/cs`, sem `lib/`. + +Relatorios: + +- `docs/sdd/etapa-4/01-roslyn-analyzers.md` +- `docs/sdd/etapa-4/02-trimming-aot.md` +- `docs/sdd/etapa-4/03-source-generator.md` diff --git a/docs/sdd/etapa-4/decisions.md b/docs/sdd/etapa-4/decisions.md index f90b4f0..33516d6 100644 --- a/docs/sdd/etapa-4/decisions.md +++ b/docs/sdd/etapa-4/decisions.md @@ -27,3 +27,13 @@ Registre aqui apenas decisoes que afetem entregas posteriores. ## Source Generator - A Entrega 03 pode reutilizar a leitura estatica de `Map(...)`, `ToColumn(...)`, `IncludeBase(...)` e `AddMap()`, mas nao deve depender de diagnostics como unica fonte de verdade. +- O Source Generator foi entregue em projeto separado `Dapper.FluentMap.Generators`, empacotado em `analyzers/dotnet/cs`, sem referencia do core para Roslyn. +- A descoberta inicial e limitada a classes de mapping declaradas na compilacao atual; assemblies referenciados nao sao percorridos automaticamente. +- A API gerada e `Dapper.FluentMap.DapperFluentMapGeneratedRegistration.AddGeneratedMappings(...)`, exposta como extension method interno no assembly consumidor. +- O codigo gerado chama somente `FluentMapConfiguration.AddMap()`, preservando o `MappingRegistry`, validacoes runtime, inheritance, conventions, naming policies e constructor mapping existentes. +- O generator e incremental, baseado em symbols, nao executa codigo do consumidor e nao instancia mappings durante a geracao. +- O caminho gerado nao usa assembly scanning, `GetTypes`, `GetExportedTypes` ou `Activator.CreateInstance(Type)`. +- `DFM005` foi reutilizado para tipos que nao satisfazem o contrato de exatamente uma interface fechada `IEntityMap` para entidade class. +- Novos diagnostics de generator: `DFM006` para mapping candidato ignorado no registro gerado e `DFM007` para duplicidade de entity maps geraveis na compilacao atual. +- Abstract maps, open generic maps, maps inacessiveis e maps sem construtor publico sem parametros sao reportados por `DFM006` e ignorados, evitando que o caminho gerado produza chamadas que nao compilam. +- O generator nao declara suporte a nested object materialization, Value Objects complexos, multiple mapping profiles, query-specific mappings, custom materializer ou generated `DbDataReader` materializer. diff --git a/docs/sdd/etapa-4/status.md b/docs/sdd/etapa-4/status.md index e1745d4..fcf645e 100644 --- a/docs/sdd/etapa-4/status.md +++ b/docs/sdd/etapa-4/status.md @@ -1,5 +1,5 @@ | Entrega | Status | Commit | |---|---|---| -| 01 - Roslyn Analyzers | Concluido | - | -| 02 - Trimming e Native AOT | Concluido | - | -| 03 - Source Generator | Pendente | - | +| 01 - Roslyn Analyzers | Concluido | 9075f61 | +| 02 - Trimming e Native AOT | Concluido | d559d65 | +| 03 - Source Generator | Concluido | este commit | diff --git a/src/Dapper.FluentMap.Generators/AnalyzerReleases.Shipped.md b/src/Dapper.FluentMap.Generators/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..9a71430 --- /dev/null +++ b/src/Dapper.FluentMap.Generators/AnalyzerReleases.Shipped.md @@ -0,0 +1,10 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn/blob/main/docs/analyzers/Analyzer%20Releases.md + +## Release 2.0.0 + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +DFM005 | Dapper.FluentMap.Configuration | Error | Generic map registration type is invalid diff --git a/src/Dapper.FluentMap.Generators/AnalyzerReleases.Unshipped.md b/src/Dapper.FluentMap.Generators/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..2698ff8 --- /dev/null +++ b/src/Dapper.FluentMap.Generators/AnalyzerReleases.Unshipped.md @@ -0,0 +1,9 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn/blob/main/docs/analyzers/Analyzer%20Releases.md + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +DFM006 | Dapper.FluentMap.Configuration | Info | Entity map type is skipped by generated registration +DFM007 | Dapper.FluentMap.Configuration | Error | Multiple generated entity maps target the same entity diff --git a/src/Dapper.FluentMap.Generators/Dapper.FluentMap.Generators.csproj b/src/Dapper.FluentMap.Generators/Dapper.FluentMap.Generators.csproj new file mode 100644 index 0000000..6f8bb5e --- /dev/null +++ b/src/Dapper.FluentMap.Generators/Dapper.FluentMap.Generators.csproj @@ -0,0 +1,26 @@ + + + Source generators for Dapper.FluentMap mapping registration. + 2.0.0 + Henk Mollema + netstandard2.0 + true + false + Dapper.FluentMap.Generators + c#;dapper;mapping;fluentmap;roslyn;source-generator + https://github.com/henkmollema/Dapper-FluentMap + MIT + README.md + true + + + + + + + + + + + + diff --git a/src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs b/src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs new file mode 100644 index 0000000..41a0bab --- /dev/null +++ b/src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs @@ -0,0 +1,404 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace Dapper.FluentMap.Generators +{ + [Generator(LanguageNames.CSharp)] + public sealed class MappingRegistrationGenerator : IIncrementalGenerator + { + public const string InvalidGenericMapRegistrationDiagnosticId = "DFM005"; + public const string SkippedGeneratedMapDiagnosticId = "DFM006"; + public const string DuplicateGeneratedEntityMapDiagnosticId = "DFM007"; + + private const string Category = "Dapper.FluentMap.Configuration"; + private const string MappingNamespace = "Dapper.FluentMap.Mapping"; + private const string GeneratedCodeHintName = "DapperFluentMapGeneratedRegistration.g.cs"; + + private static readonly DiagnosticDescriptor InvalidGenericMapRegistrationRule = new DiagnosticDescriptor( + InvalidGenericMapRegistrationDiagnosticId, + "Generic map registration type is invalid", + "Entity map type '{0}' must implement exactly one closed IEntityMap interface targeting a class type", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Generated registration can only register map types that implement exactly one closed IEntityMap interface whose entity type is a class."); + + private static readonly DiagnosticDescriptor SkippedGeneratedMapRule = new DiagnosticDescriptor( + SkippedGeneratedMapDiagnosticId, + "Entity map type is skipped by generated registration", + "Entity map type '{0}' is not included in generated registration: {1}", + Category, + DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "Only concrete, closed and accessible entity map types with a public parameterless constructor can be included in generated registration."); + + private static readonly DiagnosticDescriptor DuplicateGeneratedEntityMapRule = new DiagnosticDescriptor( + DuplicateGeneratedEntityMapDiagnosticId, + "Multiple generated entity maps target the same entity", + "Entity '{0}' has multiple generated entity maps: '{1}' and '{2}'", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Generated registration must not register more than one entity map for the same entity."); + + private static readonly SymbolDisplayFormat FullyQualifiedTypeFormat = new SymbolDisplayFormat( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers | + SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var mapCandidates = context.SyntaxProvider + .CreateSyntaxProvider( + (node, _) => IsCandidateClassDeclaration(node), + (syntaxContext, cancellationToken) => CreateMapCandidate(syntaxContext, cancellationToken)) + .Where(candidate => candidate != null) + .Collect(); + + context.RegisterSourceOutput( + mapCandidates, + (sourceProductionContext, candidates) => Execute(sourceProductionContext, candidates)); + } + + private static bool IsCandidateClassDeclaration(SyntaxNode node) + { + var classDeclaration = node as ClassDeclarationSyntax; + return classDeclaration?.BaseList != null; + } + + private static MapCandidate CreateMapCandidate( + GeneratorSyntaxContext context, + System.Threading.CancellationToken cancellationToken) + { + var classDeclaration = (ClassDeclarationSyntax)context.Node; + var mapType = context.SemanticModel.GetDeclaredSymbol(classDeclaration, cancellationToken); + if (mapType == null) + { + return null; + } + + var entityMapInterfaces = mapType.AllInterfaces + .Where(type => IsEntityMapInterface(type)) + .ToList(); + + if (entityMapInterfaces.Count == 0) + { + return null; + } + + var location = classDeclaration.Identifier.GetLocation(); + var mapDisplayName = FormatSymbol(mapType); + var mapTypeName = mapType.ToDisplayString(FullyQualifiedTypeFormat); + + if (entityMapInterfaces.Count != 1 || + entityMapInterfaces[0].TypeArguments[0].TypeKind != TypeKind.Class) + { + return MapCandidate.InvalidRegistration(mapDisplayName, location); + } + + var entityType = (INamedTypeSymbol)entityMapInterfaces[0].TypeArguments[0]; + if (mapType.IsAbstract) + { + return MapCandidate.Skipped(mapDisplayName, location, "the map type is abstract"); + } + + if (mapType.TypeParameters.Length != 0 || ContainsGenericParameters(mapType)) + { + return MapCandidate.Skipped(mapDisplayName, location, "the map type is an open generic type"); + } + + if (!IsAccessibleFromGeneratedCode(mapType)) + { + return MapCandidate.Skipped(mapDisplayName, location, "the map type is not accessible from generated code"); + } + + if (!HasPublicParameterlessConstructor(mapType)) + { + return MapCandidate.Skipped(mapDisplayName, location, "the map type does not have a public parameterless constructor"); + } + + return MapCandidate.Valid( + mapDisplayName, + mapTypeName, + entityType.ToDisplayString(FullyQualifiedTypeFormat), + GetInheritanceDepth(entityType), + location); + } + + private static void Execute( + SourceProductionContext context, + ImmutableArray candidates) + { + var distinctCandidates = candidates + .GroupBy(candidate => candidate.MapDisplayName, StringComparer.Ordinal) + .Select(group => group.First()) + .ToList(); + + foreach (var candidate in distinctCandidates) + { + ReportCandidateDiagnostic(context, candidate); + } + + var validMaps = distinctCandidates + .Where(candidate => candidate.Kind == MapCandidateKind.Valid) + .OrderBy(candidate => candidate.EntityInheritanceDepth) + .ThenBy(candidate => candidate.EntityTypeName, StringComparer.Ordinal) + .ThenBy(candidate => candidate.MapTypeName, StringComparer.Ordinal) + .ToList(); + + var duplicateEntityTypeNames = ReportDuplicateEntityMaps(context, validMaps); + var generatedMaps = validMaps + .Where(candidate => !duplicateEntityTypeNames.Contains(candidate.EntityTypeName)) + .ToList(); + + context.AddSource(GeneratedCodeHintName, SourceText.From(CreateGeneratedSource(generatedMaps), Encoding.UTF8)); + } + + private static void ReportCandidateDiagnostic(SourceProductionContext context, MapCandidate candidate) + { + if (candidate.Kind == MapCandidateKind.InvalidRegistration) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidGenericMapRegistrationRule, + candidate.Location, + candidate.MapDisplayName)); + return; + } + + if (candidate.Kind == MapCandidateKind.Skipped) + { + context.ReportDiagnostic(Diagnostic.Create( + SkippedGeneratedMapRule, + candidate.Location, + candidate.MapDisplayName, + candidate.SkipReason)); + } + } + + private static ISet ReportDuplicateEntityMaps( + SourceProductionContext context, + IList validMaps) + { + var duplicateEntityTypeNames = new HashSet(StringComparer.Ordinal); + var groups = validMaps + .GroupBy(candidate => candidate.EntityTypeName, StringComparer.Ordinal) + .Where(group => group.Count() > 1); + + foreach (var group in groups) + { + var orderedGroup = group + .OrderBy(candidate => candidate.MapTypeName, StringComparer.Ordinal) + .ToList(); + var first = orderedGroup[0]; + duplicateEntityTypeNames.Add(first.EntityTypeName); + + foreach (var duplicate in orderedGroup.Skip(1)) + { + context.ReportDiagnostic(Diagnostic.Create( + DuplicateGeneratedEntityMapRule, + duplicate.Location, + duplicate.EntityTypeName, + first.MapTypeName, + duplicate.MapTypeName)); + } + } + + return duplicateEntityTypeNames; + } + + private static string CreateGeneratedSource(IList maps) + { + var builder = new StringBuilder(); + builder.AppendLine("// "); + builder.AppendLine("namespace Dapper.FluentMap"); + builder.AppendLine("{"); + builder.AppendLine(" [global::System.CodeDom.Compiler.GeneratedCode(\"Dapper.FluentMap.Generators\", \"2.0.0\")]"); + builder.AppendLine(" internal static class DapperFluentMapGeneratedRegistration"); + builder.AppendLine(" {"); + builder.AppendLine(" public static global::Dapper.FluentMap.Configuration.FluentMapConfiguration AddGeneratedMappings("); + builder.AppendLine(" this global::Dapper.FluentMap.Configuration.FluentMapConfiguration configuration)"); + builder.AppendLine(" {"); + builder.AppendLine(" if (configuration == null)"); + builder.AppendLine(" {"); + builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(configuration));"); + builder.AppendLine(" }"); + builder.AppendLine(); + + if (maps.Count == 0) + { + builder.AppendLine(" return configuration;"); + } + else + { + builder.AppendLine(" return configuration"); + for (var index = 0; index < maps.Count; index++) + { + var terminator = index == maps.Count - 1 ? ";" : string.Empty; + builder.Append(" .AddMap<"); + builder.Append(maps[index].MapTypeName); + builder.Append(">()"); + builder.AppendLine(terminator); + } + } + + builder.AppendLine(" }"); + builder.AppendLine(" }"); + builder.AppendLine("}"); + + return builder.ToString(); + } + + private static bool IsEntityMapInterface(INamedTypeSymbol type) + { + return type.OriginalDefinition.MetadataName == "IEntityMap`1" && + type.OriginalDefinition.ContainingNamespace.ToDisplayString() == MappingNamespace; + } + + private static bool ContainsGenericParameters(INamedTypeSymbol type) + { + if (type.IsGenericType && type.TypeArguments.Any(argument => argument.Kind == SymbolKind.TypeParameter)) + { + return true; + } + + for (var containingType = type.ContainingType; containingType != null; containingType = containingType.ContainingType) + { + if (containingType.TypeParameters.Length != 0) + { + return true; + } + } + + return false; + } + + private static bool IsAccessibleFromGeneratedCode(INamedTypeSymbol type) + { + for (var current = type; current != null; current = current.ContainingType) + { + if (current.DeclaredAccessibility != Accessibility.Public && + current.DeclaredAccessibility != Accessibility.Internal) + { + return false; + } + } + + return true; + } + + private static bool HasPublicParameterlessConstructor(INamedTypeSymbol type) + { + return type.InstanceConstructors.Any(constructor => + constructor.Parameters.Length == 0 && + constructor.DeclaredAccessibility == Accessibility.Public); + } + + private static int GetInheritanceDepth(INamedTypeSymbol type) + { + var depth = 0; + for (var current = type.BaseType; current != null; current = current.BaseType) + { + depth++; + } + + return depth; + } + + private static string FormatSymbol(ISymbol symbol) + { + return symbol.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat); + } + + private sealed class MapCandidate + { + private MapCandidate( + MapCandidateKind kind, + string mapDisplayName, + string mapTypeName, + string entityTypeName, + int entityInheritanceDepth, + Location location, + string skipReason) + { + Kind = kind; + MapDisplayName = mapDisplayName; + MapTypeName = mapTypeName; + EntityTypeName = entityTypeName; + EntityInheritanceDepth = entityInheritanceDepth; + Location = location; + SkipReason = skipReason; + } + + internal MapCandidateKind Kind { get; } + + internal string MapDisplayName { get; } + + internal string MapTypeName { get; } + + internal string EntityTypeName { get; } + + internal int EntityInheritanceDepth { get; } + + internal Location Location { get; } + + internal string SkipReason { get; } + + internal static MapCandidate Valid( + string mapDisplayName, + string mapTypeName, + string entityTypeName, + int entityInheritanceDepth, + Location location) + { + return new MapCandidate( + MapCandidateKind.Valid, + mapDisplayName, + mapTypeName, + entityTypeName, + entityInheritanceDepth, + location, + null); + } + + internal static MapCandidate InvalidRegistration(string mapDisplayName, Location location) + { + return new MapCandidate( + MapCandidateKind.InvalidRegistration, + mapDisplayName, + null, + null, + 0, + location, + null); + } + + internal static MapCandidate Skipped(string mapDisplayName, Location location, string reason) + { + return new MapCandidate( + MapCandidateKind.Skipped, + mapDisplayName, + null, + null, + 0, + location, + reason); + } + } + + private enum MapCandidateKind + { + Valid, + InvalidRegistration, + Skipped + } + } +} diff --git a/src/Dapper.FluentMap.Generators/README.md b/src/Dapper.FluentMap.Generators/README.md new file mode 100644 index 0000000..039bced --- /dev/null +++ b/src/Dapper.FluentMap.Generators/README.md @@ -0,0 +1,5 @@ +# Dapper.FluentMap.Generators + +Build-time source generator for Dapper.FluentMap mapping registration. + +The generator discovers eligible `IEntityMap` implementations declared in the current compilation and emits an `AddGeneratedMappings()` extension method that registers them through the existing `AddMap()` API. diff --git a/src/Dapper.FluentMap/Properties/AssemblyInfo.cs b/src/Dapper.FluentMap/Properties/AssemblyInfo.cs index 47436f6..6040a49 100644 --- a/src/Dapper.FluentMap/Properties/AssemblyInfo.cs +++ b/src/Dapper.FluentMap/Properties/AssemblyInfo.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Dapper.FluentMap.Tests")] +[assembly: InternalsVisibleTo("Dapper.FluentMap.GeneratedRegistration.Tests")] diff --git a/test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj b/test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj index 139bc7b..d870c52 100644 --- a/test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj +++ b/test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj @@ -8,9 +8,13 @@ + ..\..\src\Dapper.FluentMap\bin\$(Configuration)\netstandard2.0\Dapper.FluentMap.dll + + + diff --git a/test/Dapper.FluentMap.AotSmoke/Program.cs b/test/Dapper.FluentMap.AotSmoke/Program.cs index 4f2be47..ee74b7f 100644 --- a/test/Dapper.FluentMap.AotSmoke/Program.cs +++ b/test/Dapper.FluentMap.AotSmoke/Program.cs @@ -5,7 +5,19 @@ using Dapper.FluentMap.Mapping; using Dapper.FluentMap.Naming; -#if AOT_SMOKE_SCANNING +#if AOT_SMOKE_GENERATED +const string scenario = "generated"; +FluentMapper.Initialize(configuration => +{ + configuration.AddGeneratedMappings(); + configuration.UseNamingPolicy(NamingPolicy.SnakeCase).ForEntity(); +}); + +AssertMappedMember("customer_id", nameof(Customer.Id)); +AssertMappedMember("created_at", nameof(NamingCustomer.CreatedAt)); +AssertConstructorMapping(); +AssertExplain(); +#elif AOT_SMOKE_SCANNING const string scenario = "scanning"; FluentMapper.Initialize(configuration => configuration.AddMapsFromAssemblyContaining()); diff --git a/test/Dapper.FluentMap.GeneratedRegistration.Tests/AssemblyInfo.cs b/test/Dapper.FluentMap.GeneratedRegistration.Tests/AssemblyInfo.cs new file mode 100644 index 0000000..2171200 --- /dev/null +++ b/test/Dapper.FluentMap.GeneratedRegistration.Tests/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/test/Dapper.FluentMap.GeneratedRegistration.Tests/Dapper.FluentMap.GeneratedRegistration.Tests.csproj b/test/Dapper.FluentMap.GeneratedRegistration.Tests/Dapper.FluentMap.GeneratedRegistration.Tests.csproj new file mode 100644 index 0000000..f7b3468 --- /dev/null +++ b/test/Dapper.FluentMap.GeneratedRegistration.Tests/Dapper.FluentMap.GeneratedRegistration.Tests.csproj @@ -0,0 +1,18 @@ + + + net10.0 + false + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + diff --git a/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs b/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs new file mode 100644 index 0000000..acc0546 --- /dev/null +++ b/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs @@ -0,0 +1,155 @@ +using System; +using Dapper; +using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.Naming; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.GeneratedRegistration.Tests +{ + public sealed class GeneratedRegistrationIntegrationTests + { + [Fact] + [Trait("Category", "Integration")] + public void GeneratedRegistrationShouldWorkWithDapperAndExistingMappingFeatures() + { + ResetMapper(); + + try + { + FluentMapper.Initialize(configuration => + { + configuration.AddGeneratedMappings(); + configuration.UseNamingPolicy(NamingPolicy.SnakeCase).ForEntity(); + }); + + using (var connection = OpenConnection()) + { + var customer = connection.QuerySingle( + "SELECT 7 AS customer_id, 'Ada' AS Name;"); + var internalCustomer = connection.QuerySingle( + "SELECT 8 AS internal_id;"); + var derived = connection.QuerySingle( + "SELECT 9 AS base_id, 'Lovelace' AS derived_name;"); + var immutable = connection.QuerySingle( + "SELECT 10 AS immutable_id, 'Grace' AS name;"); + var named = connection.QuerySingle( + "SELECT '2026-07-26T10:30:00' AS created_at;"); + + Assert.Equal(7, customer.Id); + Assert.Equal("Ada", customer.Name); + Assert.Equal(8, internalCustomer.Id); + Assert.Equal(9, derived.Id); + Assert.Equal("Lovelace", derived.Name); + Assert.Equal(10, immutable.Id); + Assert.Equal("Grace", immutable.Name); + Assert.Equal(new DateTime(2026, 7, 26, 10, 30, 0), named.CreatedAt); + } + } + finally + { + ResetMapper(); + } + } + + private static SqliteConnection OpenConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + + private static void ResetMapper() + { + FluentMapper.Reset( + typeof(GeneratedCustomer), + typeof(GeneratedInternalCustomer), + typeof(GeneratedBaseCustomer), + typeof(GeneratedDerivedCustomer), + typeof(GeneratedImmutableCustomer), + typeof(GeneratedNamingCustomer)); + } + } + + public sealed class GeneratedCustomer + { + public int Id { get; set; } + + public string Name { get; set; } + } + + public sealed class GeneratedCustomerMap : EntityMap + { + public GeneratedCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + } + } + + internal sealed class GeneratedInternalCustomer + { + public int Id { get; set; } + } + + internal sealed class GeneratedInternalCustomerMap : EntityMap + { + public GeneratedInternalCustomerMap() + { + Map(customer => customer.Id).ToColumn("internal_id"); + } + } + + public class GeneratedBaseCustomer + { + public int Id { get; set; } + } + + public sealed class GeneratedDerivedCustomer : GeneratedBaseCustomer + { + public string Name { get; set; } + } + + public sealed class GeneratedBaseCustomerMap : EntityMap + { + public GeneratedBaseCustomerMap() + { + Map(customer => customer.Id).ToColumn("base_id"); + } + } + + public sealed class GeneratedDerivedCustomerMap : EntityMap + { + public GeneratedDerivedCustomerMap() + { + IncludeBase(); + Map(customer => customer.Name).ToColumn("derived_name"); + } + } + + public sealed class GeneratedImmutableCustomer + { + public GeneratedImmutableCustomer(int id, string name) + { + Id = id; + Name = name; + } + + public int Id { get; } + + public string Name { get; } + } + + public sealed class GeneratedImmutableCustomerMap : EntityMap + { + public GeneratedImmutableCustomerMap() + { + Map(customer => customer.Id).ToColumn("immutable_id"); + Map(customer => customer.Name).ToColumn("name"); + } + } + + public sealed class GeneratedNamingCustomer + { + public DateTime CreatedAt { get; set; } + } +} diff --git a/test/Dapper.FluentMap.Generators.Tests/Dapper.FluentMap.Generators.Tests.csproj b/test/Dapper.FluentMap.Generators.Tests/Dapper.FluentMap.Generators.Tests.csproj new file mode 100644 index 0000000..e1e7a3b --- /dev/null +++ b/test/Dapper.FluentMap.Generators.Tests/Dapper.FluentMap.Generators.Tests.csproj @@ -0,0 +1,17 @@ + + + net10.0 + false + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + diff --git a/test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs b/test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs new file mode 100644 index 0000000..8dca48c --- /dev/null +++ b/test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs @@ -0,0 +1,495 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using Dapper.FluentMap; +using Dapper.FluentMap.Generators; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Xunit; + +namespace Dapper.FluentMap.Generators.Tests +{ + public sealed class MappingRegistrationGeneratorTests + { + [Fact] + public void ZeroMappingsShouldGenerateNoOpRegistration() + { + var source = @" +using Dapper.FluentMap; + +public sealed class Startup +{ + public void Configure() + { + FluentMapper.Initialize(configuration => configuration.AddGeneratedMappings()); + } +}"; + + var result = RunGenerator(source); + + Assert.Empty(result.DfmDiagnostics); + Assert.Contains("return configuration;", result.GeneratedSource, StringComparison.Ordinal); + Assert.DoesNotContain(".AddMap<", result.GeneratedSource, StringComparison.Ordinal); + } + + [Fact] + public void OneMappingShouldGenerateExplicitAddMapCall() + { + var source = @" +using Dapper.FluentMap; +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public int Id { get; set; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(customer => customer.Id).ToColumn(""customer_id""); + } +} + +public sealed class Startup +{ + public void Configure() + { + FluentMapper.Initialize(configuration => configuration.AddGeneratedMappings()); + } +}"; + + var result = RunGenerator(source); + + Assert.Empty(result.DfmDiagnostics); + Assert.Contains(".AddMap()", result.GeneratedSource, StringComparison.Ordinal); + } + + [Fact] + public void MultipleMappingsShouldBeGeneratedInDeterministicOrder() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Order +{ + public int Id { get; set; } +} + +public sealed class Customer +{ + public int Id { get; set; } +} + +public sealed class ZOrderMap : EntityMap +{ + public ZOrderMap() + { + Map(order => order.Id).ToColumn(""order_id""); + } +} + +public sealed class ACustomerMap : EntityMap +{ + public ACustomerMap() + { + Map(customer => customer.Id).ToColumn(""customer_id""); + } +}"; + + var result = RunGenerator(source); + + Assert.Empty(result.DfmDiagnostics); + Assert.True( + result.GeneratedSource.IndexOf("ACustomerMap", StringComparison.Ordinal) < + result.GeneratedSource.IndexOf("ZOrderMap", StringComparison.Ordinal)); + } + + [Fact] + public void InternalMappingShouldBeSupported() + { + var source = @" +using Dapper.FluentMap.Mapping; + +internal sealed class InternalCustomer +{ + public int Id { get; set; } +} + +internal sealed class InternalCustomerMap : EntityMap +{ + public InternalCustomerMap() + { + Map(customer => customer.Id).ToColumn(""customer_id""); + } +}"; + + var result = RunGenerator(source); + + Assert.Empty(result.DfmDiagnostics); + Assert.Contains(".AddMap()", result.GeneratedSource, StringComparison.Ordinal); + } + + [Fact] + public void AbstractMappingShouldReportSkippedDiagnostic() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public int Id { get; set; } +} + +public abstract class CustomerMapBase : EntityMap +{ +}"; + + var result = RunGenerator(source); + var diagnostic = Assert.Single(result.DfmDiagnostics); + + Assert.Equal(MappingRegistrationGenerator.SkippedGeneratedMapDiagnosticId, diagnostic.Id); + Assert.Equal(DiagnosticSeverity.Info, diagnostic.Severity); + Assert.Contains("abstract", diagnostic.GetMessage(), StringComparison.Ordinal); + Assert.DoesNotContain(".AddMap()", result.GeneratedSource, StringComparison.Ordinal); + } + + [Fact] + public void OpenGenericMappingShouldReportSkippedDiagnostic() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public int Id { get; set; } +} + +public sealed class GenericCustomerMap : EntityMap +{ + public GenericCustomerMap() + { + Map(customer => customer.Id).ToColumn(""customer_id""); + } +}"; + + var result = RunGenerator(source); + var diagnostic = Assert.Single(result.DfmDiagnostics); + + Assert.Equal(MappingRegistrationGenerator.SkippedGeneratedMapDiagnosticId, diagnostic.Id); + Assert.Contains("open generic", diagnostic.GetMessage(), StringComparison.Ordinal); + } + + [Fact] + public void DuplicateEntityMappingsShouldReportDiagnostic() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public int Id { get; set; } +} + +public sealed class FirstCustomerMap : EntityMap +{ + public FirstCustomerMap() + { + Map(customer => customer.Id).ToColumn(""customer_id""); + } +} + +public sealed class SecondCustomerMap : EntityMap +{ + public SecondCustomerMap() + { + Map(customer => customer.Id).ToColumn(""other_id""); + } +}"; + + var result = RunGenerator(source, assertCompiles: false); + var diagnostic = Assert.Single(result.DfmDiagnostics); + + Assert.Equal(MappingRegistrationGenerator.DuplicateGeneratedEntityMapDiagnosticId, diagnostic.Id); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + Assert.Contains("multiple generated entity maps", diagnostic.GetMessage(), StringComparison.Ordinal); + } + + [Fact] + public void DistinctNamespacesShouldGenerateFullyQualifiedNames() + { + var source = @" +using Dapper.FluentMap.Mapping; + +namespace Sales +{ + public sealed class Customer + { + public int Id { get; set; } + } + + public sealed class CustomerMap : EntityMap + { + public CustomerMap() + { + Map(customer => customer.Id).ToColumn(""sales_customer_id""); + } + } +} + +namespace Support +{ + public sealed class Ticket + { + public int Id { get; set; } + } + + public sealed class TicketMap : EntityMap + { + public TicketMap() + { + Map(ticket => ticket.Id).ToColumn(""ticket_id""); + } + } +}"; + + var result = RunGenerator(source); + + Assert.Empty(result.DfmDiagnostics); + Assert.Contains(".AddMap()", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains(".AddMap()", result.GeneratedSource, StringComparison.Ordinal); + } + + [Fact] + public void SameMapClassNameInDifferentNamespacesShouldGenerateBothMappings() + { + var source = @" +using Dapper.FluentMap.Mapping; + +namespace Sales +{ + public sealed class Customer + { + public int Id { get; set; } + } + + public sealed class EntityMap : Dapper.FluentMap.Mapping.EntityMap + { + public EntityMap() + { + Map(customer => customer.Id).ToColumn(""sales_customer_id""); + } + } +} + +namespace Support +{ + public sealed class Ticket + { + public int Id { get; set; } + } + + public sealed class EntityMap : Dapper.FluentMap.Mapping.EntityMap + { + public EntityMap() + { + Map(ticket => ticket.Id).ToColumn(""ticket_id""); + } + } +}"; + + var result = RunGenerator(source); + + Assert.Empty(result.DfmDiagnostics); + Assert.Contains(".AddMap()", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains(".AddMap()", result.GeneratedSource, StringComparison.Ordinal); + } + + [Fact] + public void GeneratedOutputShouldBeDeterministic() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public int Id { get; set; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(customer => customer.Id).ToColumn(""customer_id""); + } +}"; + + var first = RunGenerator(source); + var second = RunGenerator(source); + + Assert.Equal(first.GeneratedSource, second.GeneratedSource); + } + + [Fact] + public void IncrementalGeneratorShouldProduceStableOutputAcrossRepeatedRuns() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public int Id { get; set; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(customer => customer.Id).ToColumn(""customer_id""); + } +}"; + var compilation = CreateCompilation(source); + GeneratorDriver driver = CSharpGeneratorDriver.Create(new MappingRegistrationGenerator()); + + driver = driver.RunGenerators(compilation, TestContext.Current.CancellationToken); + var first = GetGeneratedSource(driver); + + driver = driver.RunGenerators(compilation, TestContext.Current.CancellationToken); + var second = GetGeneratedSource(driver); + + Assert.Equal(first, second); + } + + [Fact] + public void GeneratedRegistrationSourceShouldCompileWithConsumerCode() + { + var source = @" +using Dapper.FluentMap; +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public int Id { get; set; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(customer => customer.Id).ToColumn(""customer_id""); + } +} + +public sealed class Startup +{ + public void Configure() + { + FluentMapper.Initialize(configuration => configuration.AddGeneratedMappings()); + } +}"; + + var result = RunGenerator(source); + + Assert.Empty(result.CompilerErrors); + Assert.Contains("AddGeneratedMappings", result.GeneratedSource, StringComparison.Ordinal); + } + + private static GeneratorTestResult RunGenerator(string source, bool assertCompiles = true) + { + var compilation = CreateCompilation(source); + GeneratorDriver driver = CSharpGeneratorDriver.Create(new MappingRegistrationGenerator()); + + driver = driver.RunGeneratorsAndUpdateCompilation( + compilation, + out var outputCompilation, + out var generatorDiagnostics, + TestContext.Current.CancellationToken); + + var compilerErrors = outputCompilation + .GetDiagnostics() + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Select(diagnostic => diagnostic.ToString()) + .ToList(); + + if (assertCompiles) + { + Assert.Empty(compilerErrors); + } + + return new GeneratorTestResult( + GetGeneratedSource(driver), + generatorDiagnostics + .Where(diagnostic => diagnostic.Id.StartsWith("DFM", StringComparison.Ordinal)) + .OrderBy(diagnostic => diagnostic.Id, StringComparer.Ordinal) + .ThenBy(diagnostic => diagnostic.Location.SourceSpan.Start) + .ToList(), + compilerErrors); + } + + private static CSharpCompilation CreateCompilation(string source) + { + var syntaxTree = CSharpSyntaxTree.ParseText( + source, + path: "Test0.cs"); + + return CSharpCompilation.Create( + "GeneratorTest", + new[] { syntaxTree }, + GetMetadataReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + } + + private static string GetGeneratedSource(GeneratorDriver driver) + { + var runResult = driver.GetRunResult(); + var generatorResult = Assert.Single(runResult.Results); + var generatedSource = Assert.Single( + generatorResult.GeneratedSources, + source => source.HintName == "DapperFluentMapGeneratedRegistration.g.cs"); + + return generatedSource.SourceText.ToString(); + } + + private static IReadOnlyList GetMetadataReferences() + { + var trustedPlatformAssemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")) + .Split(Path.PathSeparator) + .Select(path => MetadataReference.CreateFromFile(path)); + + var explicitAssemblies = new[] + { + typeof(FluentMapper).Assembly.Location, + typeof(Dapper.SqlMapper).Assembly.Location + } + .Select(path => MetadataReference.CreateFromFile(path)); + + return trustedPlatformAssemblies + .Concat(explicitAssemblies) + .GroupBy(reference => reference.Display, StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .ToList(); + } + + private sealed class GeneratorTestResult + { + internal GeneratorTestResult( + string generatedSource, + IReadOnlyList dfmDiagnostics, + IReadOnlyList compilerErrors) + { + GeneratedSource = generatedSource; + DfmDiagnostics = dfmDiagnostics; + CompilerErrors = compilerErrors; + } + + internal string GeneratedSource { get; } + + internal IReadOnlyList DfmDiagnostics { get; } + + internal IReadOnlyList CompilerErrors { get; } + } + } +} From ff64f96f50e9ca176def7427911ded1f057f617b Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sun, 26 Jul 2026 13:03:55 -0300 Subject: [PATCH 11/20] test: characterize nested mapping constraints --- .../01-nested-materialization-spike.md | 494 ++++++++++++++++++ docs/sdd/etapa-5/README.md | 72 +++ docs/sdd/etapa-5/decisions.md | 28 + docs/sdd/etapa-5/status.md | 6 + .../NestedMaterializationSpikeTests.cs | 351 +++++++++++++ 5 files changed, 951 insertions(+) create mode 100644 docs/sdd/etapa-5/01-nested-materialization-spike.md create mode 100644 docs/sdd/etapa-5/README.md create mode 100644 docs/sdd/etapa-5/decisions.md create mode 100644 docs/sdd/etapa-5/status.md create mode 100644 test/Dapper.FluentMap.Tests/NestedMaterializationSpikeTests.cs diff --git a/docs/sdd/etapa-5/01-nested-materialization-spike.md b/docs/sdd/etapa-5/01-nested-materialization-spike.md new file mode 100644 index 0000000..402ffb9 --- /dev/null +++ b/docs/sdd/etapa-5/01-nested-materialization-spike.md @@ -0,0 +1,494 @@ +# 01 - Spike Nested/Value-Object Materialization + +## Specification + +Existe demanda historica para mappings como: + +```csharp +Map(x => x.Address.City).ToColumn("city"); +Map(x => x.Document.Number).ToColumn("cpf"); +``` + +A Etapa 2 introduziu `MemberPath`, portanto o core ja consegue representar: + +```text +Address.City +Document.Number +``` + +O problema desta entrega foi verificar se representar o caminho e suficiente para o Dapper materializar o grafo completo, ou se o FluentMap precisa controlar parte da materializacao. + +Casos obrigatorios avaliados: + +- nested mutable object: `Customer.Address.City`; +- dois paths com terminal igual: `Rank.Level` e `Seniority.Level`; +- Value Object imutavel: `Cpf.Number`; +- nested record: `Customer(int Id, Address Address)`. + +## Discovery + +Arquivos analisados no FluentMap: + +- `src/Dapper.FluentMap/Mapping/MemberPath.cs` +- `src/Dapper.FluentMap/Mapping/EntityMap.cs` +- `src/Dapper.FluentMap/Mapping/PropertyMap.cs` +- `src/Dapper.FluentMap/Mapping/PropertyMapIdentity.cs` +- `src/Dapper.FluentMap/MappingRegistry.cs` +- `src/Dapper.FluentMap/TypeMaps/FluentMapTypeMap.cs` +- `src/Dapper.FluentMap/TypeMaps/FluentConstructorTypeMap.cs` +- `src/Dapper.FluentMap/TypeMaps/ConstructorParameterMap.cs` +- `src/Dapper.FluentMap/TypeMaps/MultiTypeMap.cs` +- `src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs` +- testes de integracao e constructor mapping do core. + +Fontes do Dapper 2.1.79 analisadas: + +- pacote local `Dapper` 2.1.79 referenciado pelo projeto; +- tag oficial `2.1.79` do repositorio `DapperLib/Dapper`, commit `72a54c475f75e18cb93cba0809d00a5e6e49efd9`; +- `SqlMapper.ITypeMap.cs`; +- `SqlMapper.IMemberMap.cs`; +- `DefaultTypeMap.cs`; +- `CustomPropertyTypeMap.cs`; +- `SqlMapper.cs`, especialmente `GenerateDeserializerFromMap`. + +Links de referencia primaria: + +- `ITypeMap`: https://github.com/DapperLib/Dapper/blob/72a54c475f75e18cb93cba0809d00a5e6e49efd9/Dapper/SqlMapper.ITypeMap.cs +- `IMemberMap`: https://github.com/DapperLib/Dapper/blob/72a54c475f75e18cb93cba0809d00a5e6e49efd9/Dapper/SqlMapper.IMemberMap.cs +- `DefaultTypeMap`: https://github.com/DapperLib/Dapper/blob/72a54c475f75e18cb93cba0809d00a5e6e49efd9/Dapper/DefaultTypeMap.cs +- `CustomPropertyTypeMap`: https://github.com/DapperLib/Dapper/blob/72a54c475f75e18cb93cba0809d00a5e6e49efd9/Dapper/CustomPropertyTypeMap.cs +- materializer IL: https://github.com/DapperLib/Dapper/blob/72a54c475f75e18cb93cba0809d00a5e6e49efd9/Dapper/SqlMapper.cs + +### O que ITypeMap consegue fazer + +`SqlMapper.ITypeMap` consegue: + +- escolher construtor com `FindConstructor`; +- forcar construtor explicito com `FindExplicitConstructor`; +- mapear coluna para parametro de construtor com `GetConstructorParameter`; +- mapear coluna para um membro simples com `GetMember`. + +Isso e suficiente para: + +- propriedades simples; +- fields simples; +- parametros de construtor simples; +- aliases de coluna; +- constructor mapping de records/classes imutaveis quando os parametros correspondem a propriedades simples. + +### O que ITypeMap nao consegue fazer + +`ITypeMap` nao recebe nem retorna: + +- um `MemberPath`; +- uma callback de atribuicao; +- uma factory de objetos intermediarios; +- uma estrategia de nullability; +- um plano de construcao de grafo; +- um contexto de objeto raiz + caminho. + +`IMemberMap` possui apenas: + +```text +ColumnName +MemberType +PropertyInfo +FieldInfo +ParameterInfo +``` + +Nao ha contrato publico para "atribua esta coluna a Address.City criando Address se necessario". + +### Onde o setter e emitido + +No `GenerateDeserializerFromMap`, o Dapper: + +1. obtem o `ITypeMap` do tipo raiz; +2. resolve cada coluna para `IMemberMap`; +3. quando nao usa construtor especializado, emite IL para setter de propriedade ou field; +4. para propriedade, chama `DefaultTypeMap.GetPropertySetterOrThrow(item.Property, type)`; +5. para field, emite `Stfld`. + +O `type` usado e o tipo raiz que esta sendo materializado. Quando o `PropertyInfo` pertence ao tipo aninhado, o Dapper nao conhece a cadeia intermediaria. O teste de caracterizacao mostrou que devolver o leaf `Address.City` pode fazer o valor escalar ser escrito no slot errado do objeto raiz, em vez de criar `Address`. + +### Custom IMemberMap + +Um `IMemberMap` customizado nao resolve nested assignment porque ele nao contem operacao de atribuicao. Mesmo com um `ITypeMap` puro retornando o `PropertyInfo` do leaf, o Dapper continua emitindo setter simples para o tipo raiz. + +### Constructor mapping + +O `FluentConstructorTypeMap` existente filtra `MemberPath.IsNested`, por decisao da Etapa 3. Isso esta correto: parametros de construtor do tipo raiz nao sao `MemberPath`. + +Nested record falha porque o Dapper procura um construtor de `Customer` cujos parametros correspondam as colunas. A coluna `city` nao corresponde ao parametro `Address address`, nem fornece como criar `Address`. + +### TypeHandlers + +TypeHandler resolve Value Object escalar quando o destino do Dapper e o Value Object inteiro: + +```csharp +Map(x => x.Cpf).ToColumn("cpf"); +``` + +Com um `SqlMapper.TypeHandler`, o Dapper converte `varchar -> Cpf` e atribui `Cpf`. + +TypeHandler nao resolve: + +```csharp +Map(x => x.Cpf.Number).ToColumn("cpf"); +``` + +Nesse caso o destino exposto ao Dapper e o membro terminal `Number`, cujo tipo e `string`. O handler de `Cpf` nao participa, e a cadeia `Customer.Cpf` nao e criada. + +### Multi-mapping + +Multi-mapping do Dapper (`Query`) materializa varios objetos em segmentos de coluna e delega composicao a uma callback do consumidor. Ele pode ser usado pelo usuario para compor `Customer` + `Address`, mas nao e uma boa base interna generica para nested mapping arbitrario porque: + +- exige conhecimento de `splitOn`; +- segmenta por tipos, nao por `MemberPath`; +- nao resolve multiplos paths para o mesmo tipo ou mesmo terminal; +- nao cobre bem Value Objects escalares; +- mudaria demais a API para consultas simples. + +### Source generation + +O generator da Etapa 4 gera apenas registro: + +```csharp +configuration.AddMap(); +``` + +Ele nao le `DbDataReader`, nao gera assignment e nao materializa objetos. Porem, a infraestrutura pode ser evoluida no futuro para gerar materializers especializados, o que e interessante para: + +- performance; +- Native AOT; +- trimming; +- records; +- grafos imutaveis. + +## Experimentos + +Arquivo adicionado: + +- `test/Dapper.FluentMap.Tests/NestedMaterializationSpikeTests.cs` + +Testes de caracterizacao: + +| Teste | Evidencia | +|---|---| +| `NestedMutablePathShouldWriteLeafValueIntoRootSlotInsteadOfMaterializingGraph` | `Map(x => x.Address.City)` nao cria `Address`; o valor do leaf aparece no slot do root, evidenciando que Dapper recebeu apenas o terminal. | +| `NestedPathsWithSameTerminalShouldBeConfiguredButDapperStillReceivesOnlyTerminalMembers` | `Rank.Level` e `Seniority.Level` coexistem em `Explain`, mas a materializacao por Dapper nao preserva os caminhos. | +| `TypeHandlerShouldMaterializeScalarValueObjectProperty` | `TypeHandler` funciona quando o destino e `Customer.Cpf`. | +| `TypeHandlerShouldNotMaterializeNestedValueObjectPath` | `TypeHandler` nao participa quando o mapping e `Customer.Cpf.Number`. | +| `NestedRecordShouldNotMaterializeThroughConstructorMapping` | Nested record falha por ausencia de construtor correspondente a colunas planas. | +| `PureITypeMapReturningNestedLeafPropertyShouldWriteLeafValueIntoRootSlot` | Mesmo sem FluentMap, `ITypeMap` puro retornando leaf `PropertyInfo` nao representa nested assignment. | + +Validacao localizada: + +```text +dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~NestedMaterializationSpikeTests" +``` + +Resultado: + +```text +6 testes aprovados +``` + +## Alternativas + +### Alternativa A - ITypeMap puro + +Vantagens: + +- maxima compatibilidade com `Dapper.Query`; +- pouca API nova; +- baixo custo inicial. + +Limites comprovados: + +- `IMemberMap` so possui `PropertyInfo`, `FieldInfo` ou `ParameterInfo`; +- nao ha callback de assignment; +- nao ha criacao de intermediarios; +- devolver o `PropertyInfo` terminal pode escrever no slot errado do objeto raiz; +- nao suporta nested records ou Value Objects aninhados. + +Conclusao: + +```text +Rejeitada como arquitetura principal. +``` + +### Alternativa B - Dapper TypeHandler + +Vantagens: + +- usa mecanismo publico do Dapper; +- bom para `varchar -> Cpf`, `int -> Money`, etc.; +- baixo custo; +- compoe com constructor mapping simples. + +Limites: + +- funciona por tipo de destino, nao por caminho; +- nao cria `Customer.Cpf`; +- nao resolve `Customer.Cpf.Number`; +- nao constroi grafos imutaveis. + +Conclusao: + +```text +Aceita como estrategia complementar para Value Objects escalares. +``` + +### Alternativa C - Wrapper de Query + +Exemplo conceitual: + +```csharp +connection.QueryMapped(sql, param); +``` + +Vantagens: + +- caminho opt-in, preservando `Dapper.Query`; +- permite controlar `DbDataReader`, nullability, criacao de intermediarios e assignments por `MemberPath`; +- permite rejeitar cenarios nao suportados com diagnostico claro; +- nao exige fork do Dapper. + +Custos: + +- nova API paralela; +- precisa implementar plano de materializacao; +- precisa definir conversoes, TypeHandlers, cache e diagnostico; +- pode duplicar parte pequena da materializacao simples. + +Conclusao: + +```text +Direcao principal para Entrega 2. +``` + +### Alternativa D - Source-generated materializer + +Vantagens: + +- melhor potencial de performance; +- melhor caminho para trimming e Native AOT; +- pode gerar codigo direto para records, construtores e Value Objects; +- reduz reflection no hot path. + +Custos: + +- complexidade alta; +- exige projeto generator mais ambicioso; +- nao cobre configuracao dinamica; +- aumenta custo de manutencao. + +Conclusao: + +```text +Estrategia futura/complementar, especialmente para Entrega 3 e AOT. +``` + +### Alternativa E - Post-materialization/intermediario + +Modelo: + +```text +DbDataReader ou DapperRow + -> valores por coluna + -> plano FluentMap + -> objeto final +``` + +Vantagens: + +- evita depender de internals do Dapper; +- permite usar Dapper para executar comando e obter valores; +- controla nested paths de forma deterministica; +- pode cachear planos por tipo e shape de colunas. + +Custos: + +- alocacao de representacao intermediaria se usar `DapperRow`; +- conversoes precisam ser definidas; +- objetos imutaveis exigem fase de construcao distinta. + +Conclusao: + +```text +Provavel implementacao inicial do wrapper de Query. +``` + +## Tabela Comparativa + +| Criterio | A - ITypeMap puro | B - TypeHandler | C - Query wrapper | D - Source-generated materializer | E - Post-materialization | +|---|---|---|---|---|---| +| Compatibilidade com API atual | Alta | Alta | Media, API nova opt-in | Media, exige generator | Media, API nova opt-in | +| Complexidade | Baixa | Baixa | Media | Alta | Media | +| Performance | Alta quando simples, invalida para nested | Alta | Media | Alta | Media | +| AOT/trimming | Limitado pelo Dapper | Igual Dapper | Reflection-sensitive se runtime | Melhor potencial | Reflection-sensitive se runtime | +| Records | Simples apenas | Escalar apenas | Possivel | Melhor opcao | Possivel com plano | +| Value Objects | Nao | Escalares | Possivel | Possivel | Possivel | +| Nested mutable objects | Nao seguro | Nao | Sim | Sim | Sim | +| Nested immutable objects | Nao | Nao | Possivel com construtores | Sim | Possivel com construtores | +| Debuggability | Baixa para nested | Alta | Alta se diagnostico proprio | Media | Alta | +| Manutenibilidade | Ruim para nested | Boa | Boa se escopo estreito | Mais cara | Boa se escopo estreito | +| Dependencia de internals do Dapper | Baixa, mas insuficiente | Baixa | Baixa | Baixa/media | Baixa | + +## Decision + +Direcao principal: + +```text +Nested materialization deve ser implementada por caminho opt-in controlado pelo FluentMap, provavelmente `QueryMapped`, usando um plano de materializacao baseado em MemberPath. +``` + +Estrategia complementar: + +```text +Value Objects escalares devem continuar usando Dapper TypeHandlers quando o destino mapeado e o Value Object inteiro. +``` + +Estrategia futura: + +```text +Source-generated materializers devem ser avaliados para nested immutable graphs, records e cenarios trimmed/AOT, mas nao sao pre-requisito para iniciar nested mutable objects. +``` + +### API publica provavel + +Ainda nao definitiva: + +```csharp +connection.QueryMapped(sql, param); +connection.QueryMappedSingle(sql, param); +``` + +Regras provaveis: + +- API opt-in em namespace `Dapper.FluentMap`; +- nao substituir `Dapper.Query`; +- usar mappings registrados no `MappingRegistry`; +- aceitar somente cenarios validados inicialmente; +- falhar com diagnostico claro quando path intermediario nao puder ser criado. + +### O que continua usando Dapper normal + +- mappings simples; +- conventions e naming policies simples; +- constructor mapping simples; +- records posicionais simples; +- fallback default do Dapper; +- TypeHandlers escalares. + +### Quando FluentMap precisa controlar materializacao + +FluentMap precisa controlar quando houver: + +- `MemberPath.IsNested`; +- criacao de objetos intermediarios; +- nested Value Object; +- nested record; +- grafo imutavel; +- necessidade de preservar dois paths com mesmo terminal; +- nullability ou ausencia de intermediario. + +## Impacto Em AOT E Performance + +Runtime wrapper/reflection: + +- menor custo de implementacao; +- bom para provar semantics da Entrega 2; +- precisa cachear planos por tipo e shape de colunas; +- sera trimming-sensitive se depender de reflection ampla. + +Source-generated materializer: + +- melhor caminho para AOT; +- pode remover reflection do hot path; +- deve reaproveitar metadata estatica do generator da Etapa 4; +- aumenta complexidade e deve ser entregue separadamente. + +TypeHandler: + +- performance boa e integrada ao Dapper; +- AOT depende do proprio handler e do Dapper; +- nao cobre nested path. + +## Riscos + +- Escrever nested paths no type map atual do Dapper pode produzir atribuicoes incorretas; a Entrega 2 deve neutralizar esse caminho. +- Criar `QueryMapped` amplia superficie publica e precisa de nomes, overloads e comportamento compativeis. +- Conversoes devem respeitar TypeHandlers sem copiar internals do Dapper. +- Nullability de intermediarios precisa de regra explicita: criar, preservar null ou falhar. +- Grafos imutaveis exigem construtor/factory e nao devem ser misturados com a primeira entrega de mutable nested objects sem testes suficientes. +- Cache de planos deve incluir tipo, colunas, ordem e configuracao que altera resultado. + +## Instrucoes Para Entrega 2 + +- Comecar por nested mutable object com construtor sem parametros e propriedades settable. +- Criar API opt-in em vez de prometer suporte via `Dapper.Query`. +- Rejeitar paths cuja cadeia intermediaria nao tenha setter ou construtor suportado. +- Criar objetos intermediarios apenas quando a coluna do leaf tiver valor materializavel. +- Preservar dois paths com mesmo terminal (`Rank.Level` e `Seniority.Level`) usando `MemberPath` completo. +- Adicionar diagnostico claro para caminhos nao suportados. +- Alterar o type map atual para nao devolver leaf `PropertyInfo` aninhado ao Dapper como se fosse propriedade simples. + +## Instrucoes Para Entrega 3 + +- Suportar Value Objects escalares primeiro via documentacao/testes de TypeHandler. +- Para Value Objects imutaveis aninhados, definir como construir o objeto: TypeHandler, construtor unico, factory explicita ou materializer gerado. +- Nao tratar `Cpf.Number` como equivalente automatico a `Cpf`. +- Records aninhados devem passar por plano de construtor, nao por setter terminal. +- Avaliar source generation quando o runtime reflection-based ficar complexo ou produzir warnings AOT relevantes. + +## Delivery + +Arquivos adicionados: + +- `test/Dapper.FluentMap.Tests/NestedMaterializationSpikeTests.cs` +- `docs/sdd/etapa-5/README.md` +- `docs/sdd/etapa-5/status.md` +- `docs/sdd/etapa-5/decisions.md` +- `docs/sdd/etapa-5/01-nested-materialization-spike.md` + +Nao foram alterados: + +- codigo de producao do core; +- Dommel; +- TargetFrameworks; +- metadados de pacote; +- source generator. + +## Validation + +Validacao localizada executada durante o spike: + +```text +dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~NestedMaterializationSpikeTests" +``` + +Resultado: + +```text +Sucesso, 6 testes aprovados. +``` + +Validacao final deve executar: + +```text +dotnet restore +dotnet build +dotnet test +dotnet build --configuration Release +dotnet test --configuration Release +``` + +## Semantic Commit + +Mensagem planejada: + +```text +test: characterize nested mapping constraints +``` diff --git a/docs/sdd/etapa-5/README.md b/docs/sdd/etapa-5/README.md new file mode 100644 index 0000000..e5fe689 --- /dev/null +++ b/docs/sdd/etapa-5/README.md @@ -0,0 +1,72 @@ +# Etapa 5 + +## Objetivo + +Investigar e evoluir o `Dapper.FluentMap` para suportar, de forma segura e opt-in, materializacao de objetos aninhados, Value Objects imutaveis e perfis de mapping, sem transformar a biblioteca em ORM, query builder ou camada de CRUD. + +## Dependencia Das Etapas 1 A 4 + +Esta etapa depende das decisoes anteriores sobre: + +- `MemberPath` como identidade interna de caminho; +- `MappingRegistry`, cache estruturado e precedencia efetiva; +- constructor mapping para propriedades simples; +- records e tipos imutaveis simples; +- API publica `Validate()` e `Explain()`; +- limites atuais do `ITypeMap` do Dapper; +- trimming, Native AOT e source generation. + +Nenhuma decisao das etapas anteriores deve ser revertida sem evidencia tecnica registrada nesta pasta. + +## Leitura Obrigatoria + +Antes de iniciar qualquer entrega desta etapa, leia: + +- `docs/sdd/etapa-1/README.md` +- `docs/sdd/etapa-1/decisions.md` +- `docs/sdd/etapa-2/README.md` +- `docs/sdd/etapa-2/decisions.md` +- `docs/sdd/etapa-2/01-member-path.md` +- `docs/sdd/etapa-2/03-inherited-mappings.md` +- `docs/sdd/etapa-3/README.md` +- `docs/sdd/etapa-3/decisions.md` +- `docs/sdd/etapa-3/02-constructor-immutable-mapping.md` +- `docs/sdd/etapa-3/03-diagnostics-api.md` +- `docs/sdd/etapa-4/README.md` +- `docs/sdd/etapa-4/decisions.md` +- `docs/sdd/etapa-4/02-trimming-aot.md` +- `docs/sdd/etapa-4/03-source-generator.md` +- `docs/sdd/etapa-5/decisions.md` +- relatorios ja concluidos em `docs/sdd/etapa-5/`. + +## Escopo + +O escopo padrao continua sendo o projeto principal `Dapper.FluentMap` e seus testes. + +`Dapper.FluentMap.Dommel` nao deve receber alteracao funcional nesta etapa, salvo se uma mudanca comprovada no core exigir adaptacao explicita e documentada. + +## Compatibilidade + +- Preserve a API publica existente sempre que possivel. +- Preserve `netstandard2.0` nos projetos de `src/`. +- Nao altere os TargetFrameworks atuais sem decisao arquitetural futura e especifica. +- Nao mude o comportamento de `Dapper.Query` para prometer nested materialization implicitamente. +- Qualquer nova capacidade de materializacao aninhada deve ser opt-in e testada com Dapper real. + +## Fora Do Escopo + +Esta etapa nao deve transformar o FluentMap em: + +- ORM; +- query builder; +- gerador de SQL; +- camada de CRUD; +- change tracker; +- unit of work. + +## Entregas + +1. 01 - Spike de nested/value-object materialization +2. 02 - Nested object materialization +3. 03 - Value Objects imutaveis +4. 04 - Mapping profiles diff --git a/docs/sdd/etapa-5/decisions.md b/docs/sdd/etapa-5/decisions.md new file mode 100644 index 0000000..2f63760 --- /dev/null +++ b/docs/sdd/etapa-5/decisions.md @@ -0,0 +1,28 @@ +# Decisoes Da Etapa 5 + +Registre aqui apenas decisoes arquiteturais necessarias as proximas entregas. + +## Nested Materialization + +- `MemberPath` continua sendo identidade e diagnostico de caminho; ele nao deve ser entregue diretamente ao Dapper como `PropertyInfo` terminal para simular nested assignment. +- `Dapper.Query` com o `ITypeMap` atual do Dapper permanece suportado para mappings simples, constructor mapping simples, conventions, naming policies e fallback. +- Nested object materialization deve ser opt-in por um caminho controlado pelo FluentMap, provavelmente uma API paralela de consulta/materializacao como `QueryMapped`. +- O caminho opt-in deve ler os valores do reader ou de uma representacao intermediaria e aplicar um plano de materializacao baseado em `MemberPath`. +- A Entrega 2 deve impedir que nested paths sejam tratados como propriedades simples pelo type map instalado no Dapper, porque isso pode escrever o valor do leaf no slot errado do objeto raiz. + +## Value Objects + +- Value Objects escalares devem usar o mecanismo publico de TypeHandlers do Dapper quando o mapping aponta para a propriedade Value Object inteira, por exemplo `Map(x => x.Cpf).ToColumn("cpf")`. +- TypeHandler nao resolve nested path arbitrario como `Map(x => x.Cpf.Number).ToColumn("cpf")`, porque o Dapper passa a converter e atribuir o membro terminal (`Number`), nao o Value Object (`Cpf`). +- Value Objects imutaveis dentro de grafos aninhados exigem materializacao controlada pelo FluentMap ou geracao de materializer; nao devem ser declarados suportados por `ITypeMap` puro. + +## Records E Imutabilidade + +- Records posicionais e classes imutaveis simples continuam sendo responsabilidade do constructor mapping existente quando todos os parametros sao simples. +- Nested records, nested immutable objects e construcao de Value Objects por construtor devem ser tratados por uma estrategia complementar ao `ITypeMap` do Dapper. + +## Source Generation, Trimming E AOT + +- O generator da Etapa 4 continua limitado a registro de mappings. +- Um materializer gerado pode ser uma estrategia futura para performance, trimming e Native AOT, mas nao deve ser acoplado a Entrega 2 como unico caminho. +- O caminho runtime/reflection-based deve ser documentado como menos AOT-friendly; o caminho gerado deve ser a opcao preferencial para consumidores trimmed/AOT quando existir. diff --git a/docs/sdd/etapa-5/status.md b/docs/sdd/etapa-5/status.md new file mode 100644 index 0000000..1dac37a --- /dev/null +++ b/docs/sdd/etapa-5/status.md @@ -0,0 +1,6 @@ +| Entrega | Status | Commit | +|---|---|---| +| 01 - Spike nested/value-object | Concluido | - | +| 02 - Nested object materialization | Pendente | - | +| 03 - Value Objects imutaveis | Pendente | - | +| 04 - Mapping profiles | Pendente | - | diff --git a/test/Dapper.FluentMap.Tests/NestedMaterializationSpikeTests.cs b/test/Dapper.FluentMap.Tests/NestedMaterializationSpikeTests.cs new file mode 100644 index 0000000..227d0e8 --- /dev/null +++ b/test/Dapper.FluentMap.Tests/NestedMaterializationSpikeTests.cs @@ -0,0 +1,351 @@ +using System; +using System.Reflection; +using Dapper; +using Dapper.FluentMap.Mapping; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class NestedMaterializationSpikeTests + { + [Fact] + [Trait("Category", "Integration")] + public void NestedMutablePathShouldWriteLeafValueIntoRootSlotInsteadOfMaterializingGraph() + { + PreTest(typeof(NestedMutableCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new NestedMutableCustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QuerySingle("SELECT 'Recife' AS city;"); + + var assignedValue = (object)customer.Address; + + Assert.IsType(assignedValue); + Assert.Equal("Recife", assignedValue); + } + } + finally + { + PreTest(typeof(NestedMutableCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void NestedPathsWithSameTerminalShouldBeConfiguredButDapperStillReceivesOnlyTerminalMembers() + { + PreTest(typeof(SameTerminalCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new SameTerminalCustomerMap())); + + var explanation = FluentMapper.Explain(); + Assert.Contains(explanation.Members, m => m.MemberPath == "Rank.Level" && m.ColumnName == "rank_level"); + Assert.Contains(explanation.Members, m => m.MemberPath == "Seniority.Level" && m.ColumnName == "seniority_level"); + + using (var connection = OpenConnection()) + { + var customer = connection.QuerySingle( + "SELECT 'gold' AS rank_level, 'staff' AS seniority_level;"); + + Assert.Equal("staff", (object)customer.Rank); + Assert.Null(customer.Seniority); + } + } + finally + { + PreTest(typeof(SameTerminalCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void TypeHandlerShouldMaterializeScalarValueObjectProperty() + { + PreTest(typeof(ScalarValueObjectCustomer)); + + try + { + SqlMapper.AddTypeHandler(new CpfTypeHandler()); + FluentMapper.Initialize(c => c.AddMap(new ScalarValueObjectCustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QuerySingle( + "SELECT '12345678909' AS cpf;"); + + Assert.NotNull(customer.Cpf); + Assert.Equal("12345678909", customer.Cpf.Number); + } + } + finally + { + SqlMapper.ResetTypeHandlers(); + PreTest(typeof(ScalarValueObjectCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void TypeHandlerShouldNotMaterializeNestedValueObjectPath() + { + PreTest(typeof(NestedValueObjectCustomer)); + + try + { + SqlMapper.AddTypeHandler(new CpfTypeHandler()); + FluentMapper.Initialize(c => c.AddMap(new NestedValueObjectCustomerMap())); + + using (var connection = OpenConnection()) + { + var exception = Assert.ThrowsAny(() => + connection.QuerySingle( + "SELECT '12345678909' AS cpf;")); + + Assert.Contains("Number", exception.ToString(), StringComparison.Ordinal); + } + } + finally + { + SqlMapper.ResetTypeHandlers(); + PreTest(typeof(NestedValueObjectCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void NestedRecordShouldNotMaterializeThroughConstructorMapping() + { + PreTest(typeof(RecordCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new RecordCustomerMap())); + + using (var connection = OpenConnection()) + { + var exception = Assert.Throws(() => + connection.QuerySingle( + "SELECT 42 AS customer_id, 'Olinda' AS city;")); + + Assert.Contains("constructor", exception.Message, StringComparison.OrdinalIgnoreCase); + } + } + finally + { + PreTest(typeof(RecordCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void PureITypeMapReturningNestedLeafPropertyShouldWriteLeafValueIntoRootSlot() + { + PreTest(typeof(PureTypeMapCustomer)); + + try + { + SqlMapper.SetTypeMap( + typeof(PureTypeMapCustomer), + new LeafPropertyTypeMap(typeof(PureTypeMapAddress).GetProperty(nameof(PureTypeMapAddress.City)))); + + using (var connection = OpenConnection()) + { + var customer = connection.QuerySingle("SELECT 'Natal' AS city;"); + + var assignedValue = (object)customer.Address; + + Assert.IsType(assignedValue); + Assert.Equal("Natal", assignedValue); + } + } + finally + { + PreTest(typeof(PureTypeMapCustomer)); + } + } + + private static SqliteConnection OpenConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + + private static void PreTest(params Type[] types) + { + FluentMapper.Reset(types); + } + + private sealed class NestedMutableCustomer + { + public NestedMutableAddress Address { get; set; } + } + + private sealed class NestedMutableAddress + { + public string City { get; set; } + } + + private sealed class NestedMutableCustomerMap : EntityMap + { + public NestedMutableCustomerMap() + { + Map(customer => customer.Address.City).ToColumn("city"); + } + } + + private sealed class SameTerminalCustomer + { + public RankInfo Rank { get; set; } + + public SeniorityInfo Seniority { get; set; } + } + + private sealed class RankInfo + { + public string Level { get; set; } + } + + private sealed class SeniorityInfo + { + public string Level { get; set; } + } + + private sealed class SameTerminalCustomerMap : EntityMap + { + public SameTerminalCustomerMap() + { + Map(customer => customer.Rank.Level).ToColumn("rank_level"); + Map(customer => customer.Seniority.Level).ToColumn("seniority_level"); + } + } + + private sealed class ScalarValueObjectCustomer + { + public Cpf Cpf { get; set; } + } + + private sealed class ScalarValueObjectCustomerMap : EntityMap + { + public ScalarValueObjectCustomerMap() + { + Map(customer => customer.Cpf).ToColumn("cpf"); + } + } + + private sealed class NestedValueObjectCustomer + { + public Cpf Cpf { get; set; } + } + + private sealed class NestedValueObjectCustomerMap : EntityMap + { + public NestedValueObjectCustomerMap() + { + Map(customer => customer.Cpf.Number).ToColumn("cpf"); + } + } + + private sealed class Cpf + { + public Cpf(string number) + { + Number = number; + } + + public string Number { get; } + } + + private sealed class CpfTypeHandler : SqlMapper.TypeHandler + { + public override Cpf Parse(object value) + { + return new Cpf((string)value); + } + + public override void SetValue(System.Data.IDbDataParameter parameter, Cpf value) + { + parameter.Value = value == null ? DBNull.Value : value.Number; + } + } + + private sealed record RecordAddress(string City); + + private sealed record RecordCustomer(int Id, RecordAddress Address); + + private sealed class RecordCustomerMap : EntityMap + { + public RecordCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Address.City).ToColumn("city"); + } + } + + private sealed class PureTypeMapCustomer + { + public PureTypeMapAddress Address { get; set; } + } + + private sealed class PureTypeMapAddress + { + public string City { get; set; } + } + + private sealed class LeafPropertyTypeMap : SqlMapper.ITypeMap + { + private readonly PropertyInfo _property; + + public LeafPropertyTypeMap(PropertyInfo property) + { + _property = property; + } + + public ConstructorInfo FindConstructor(string[] names, Type[] types) + { + return typeof(PureTypeMapCustomer).GetConstructor(Type.EmptyTypes); + } + + public ConstructorInfo FindExplicitConstructor() + { + return null; + } + + public SqlMapper.IMemberMap GetConstructorParameter(ConstructorInfo constructor, string columnName) + { + return null; + } + + public SqlMapper.IMemberMap GetMember(string columnName) + { + return new LeafMemberMap(columnName, _property); + } + } + + private sealed class LeafMemberMap : SqlMapper.IMemberMap + { + public LeafMemberMap(string columnName, PropertyInfo property) + { + ColumnName = columnName; + Property = property; + } + + public string ColumnName { get; } + + public Type MemberType => Property.PropertyType; + + public PropertyInfo Property { get; } + + public FieldInfo Field => null; + + public ParameterInfo Parameter => null; + } + } +} From 2ed4af57ae95d350b50f8b44bc4f04b6eaabcc71 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sun, 26 Jul 2026 13:41:16 -0300 Subject: [PATCH 12/20] feat: support nested object mappings --- README.md | 19 + .../02-nested-object-materialization.md | 271 +++++++ docs/sdd/etapa-5/decisions.md | 12 +- docs/sdd/etapa-5/status.md | 2 +- .../Compatibility/CodeAnalysisAttributes.cs | 17 + .../Diagnostics/MappingMaterialization.cs | 18 + .../Diagnostics/MemberMappingExplanation.cs | 9 +- .../MappingConfigurationValidator.cs | 124 ++++ src/Dapper.FluentMap/MappingRegistry.cs | 48 +- .../MaterializationPlanCacheKey.cs | 82 +++ .../NestedMaterializationPlan.cs | 341 +++++++++ src/Dapper.FluentMap/QueryMappedExtensions.cs | 112 +++ .../InheritedMappingTests.cs | 11 +- .../NestedMaterializationSpikeTests.cs | 23 +- .../NestedObjectMaterializationTests.cs | 677 ++++++++++++++++++ 15 files changed, 1739 insertions(+), 27 deletions(-) create mode 100644 docs/sdd/etapa-5/02-nested-object-materialization.md create mode 100644 src/Dapper.FluentMap/Diagnostics/MappingMaterialization.cs create mode 100644 src/Dapper.FluentMap/Materialization/MaterializationPlanCacheKey.cs create mode 100644 src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs create mode 100644 src/Dapper.FluentMap/QueryMappedExtensions.cs create mode 100644 test/Dapper.FluentMap.Tests/NestedObjectMaterializationTests.cs diff --git a/README.md b/README.md index 38d7ece..95781d3 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,25 @@ public class ProductMap : EntityMap Column names are mapped case sensitive by default. You can change this by specifying the `caseSensitive` parameter in the `ToColumn()` method: `Map(p => p.Name).ToColumn("strName", caseSensitive: false)`. +#### Nested object materialization +Nested paths can be configured with the same `Map(...)` API, but materializing the object graph is opt-in. Use `QueryMapped()` or `QueryMappedSingle()` when you want FluentMap to create supported mutable intermediate objects: + +```csharp +public class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(c => c.Address.City) + .ToColumn("city"); + } +} + +var customer = connection.QueryMappedSingle( + "SELECT 'Sao Paulo' AS city"); +``` + +The regular `Dapper.Query()` path continues to handle root properties, conventions, constructor mapping and Dapper fallback as before. The nested materializer supports mutable intermediate objects with public parameterless constructors and settable properties; immutable nested value objects and nested records are reserved for a generated or constructor-based materializer. + **Initialization:** ```csharp FluentMapper.Initialize(config => diff --git a/docs/sdd/etapa-5/02-nested-object-materialization.md b/docs/sdd/etapa-5/02-nested-object-materialization.md new file mode 100644 index 0000000..dd96abf --- /dev/null +++ b/docs/sdd/etapa-5/02-nested-object-materialization.md @@ -0,0 +1,271 @@ +# 02 - Nested Object Materialization + +## Specification + +Esta entrega implementa suporte real e opt-in para materializar objetos aninhados mutaveis a partir de mappings baseados em `MemberPath`. + +Exemplo suportado: + +```csharp +Map(x => x.Address.City).ToColumn("city"); + +var customer = connection.QueryMappedSingle( + "SELECT 'Sao Paulo' AS city;"); +``` + +Resultado: + +```text +Customer +└── Address + └── City = "Sao Paulo" +``` + +O caminho regular `Dapper.Query` permanece preservado para mappings simples, constructor mapping simples, conventions, naming policies e fallback do Dapper. Nested materialization nao e prometida implicitamente por `Dapper.Query`. + +## Discovery + +Arquivos analisados: + +- `docs/sdd/etapa-5/01-nested-materialization-spike.md` +- `docs/sdd/etapa-2/01-member-path.md` +- `docs/sdd/etapa-3/02-constructor-immutable-mapping.md` +- `docs/sdd/etapa-4/02-trimming-aot.md` +- `docs/sdd/etapa-4/03-source-generator.md` +- `src/Dapper.FluentMap/MappingRegistry.cs` +- `src/Dapper.FluentMap/MappingConfigurationValidator.cs` +- `src/Dapper.FluentMap/Mapping/MemberPath.cs` +- `src/Dapper.FluentMap/TypeMaps/FluentConstructorTypeMap.cs` +- `src/Dapper.FluentMap/TypeMaps/FluentMapTypeMap.cs` +- `src/Dapper.FluentMap/Diagnostics/*` +- testes de integracao com SQLite. + +Confirmacao obrigatoria: + +```text +01 - Spike nested/value-object -> Concluido +``` + +## Decision + +A arquitetura escolhida no spike foi mantida: + +- nested materialization usa API opt-in controlada pelo FluentMap; +- `Dapper.Query` nao tenta materializar grafos aninhados; +- `MemberPath` e a identidade do caminho; +- o plano de materializacao e criado a partir do shape de colunas; +- o plano usa a precedencia efetiva do `MappingRegistry`: explicito, herdado, convention/naming policy e fallback do Dapper para membros raiz. + +API adicionada: + +```csharp +connection.QueryMapped(sql, param, transaction, commandTimeout, commandType); +connection.QueryMappedSingle(sql, param, transaction, commandTimeout, commandType); +``` + +## Supported Scope + +Suportado nesta entrega: + +- objetos aninhados mutaveis; +- um nivel, por exemplo `Customer.Address.City`; +- multiplos niveis, por exemplo `Customer.Address.Country.Name`; +- paths com mesmo terminal, por exemplo `Rank.Level` e `Seniority.Level`; +- mappings explicitos e herdados; +- naming policies e conventions para propriedades raiz; +- fallback tradicional de POCO raiz settable; +- multiplas linhas; +- `Explain` indicando `Materialization = Nested`. + +Fora do escopo: + +- Value Objects imutaveis aninhados; +- nested records; +- construcao de grafos imutaveis por construtor; +- collections no meio do path; +- indexers, static members e paths readonly; +- materializer gerado. + +## Null Semantics + +As regras sao por subarvore nested: + +- se todos os valores correspondentes a uma subarvore nested forem `NULL`, o objeto intermediario dessa subarvore fica `null`; +- se o root ou um intermediario ja criou esse objeto por construtor/inicializador, o materializer limpa a propriedade para `null` quando ela e settable; +- se pelo menos um valor da subarvore nested nao for `NULL`, o objeto intermediario e criado quando estiver `null`; +- valores leaf `NULL` dentro de uma subarvore criada sao atribuidos como `null` para reference/nullable types ou como default para value types nao anulaveis; +- nulabilidade C# por NRT nao e interpretada nesta entrega, porque o core permanece sem nullable annotations ponta a ponta. + +Assim: + +```text +city = NULL +``` + +mantem `Address = null` quando `Address.City` e o unico valor nested. + +E: + +```text +city = NULL, postal_code = '01000' +``` + +cria `Address`, define `City = null` e `PostalCode = '01000'`. + +## Construction Semantics + +Para nested materialization runtime: + +- o tipo raiz consultado por `QueryMapped*` deve ter construtor publico sem parametros; +- cada propriedade intermediaria deve ter getter publico, setter publico e tipo com construtor publico sem parametros; +- cada leaf nested deve ser settable; +- objetos intermediarios existentes sao reutilizados quando a subarvore possui dados; +- falhas de construcao sao reportadas como `FluentMapConfigurationException` durante a criacao do plano `QueryMapped*`, antes da materializacao das linhas. + +Nao ha reflection para construtores privados nesta entrega. + +## Validation + +A validacao de configuracao rejeita antecipadamente: + +- membro intermediario sem getter/setter publico; +- leaf nested readonly; +- collection no meio do path; +- indexer; +- static member; +- prefix conflict, como mapear `Address` e `Address.City` ao mesmo tempo. + +A validacao do plano `QueryMapped*` rejeita, antes de ler linhas, tipo raiz ou intermediario sem construtor publico sem parametros. Essa checagem fica no caminho opt-in anotado para trimming/dynamic-code para preservar o caminho de registro explicito sem warnings FluentMap-owned. + +## Performance + +O caminho opt-in cria e cacheia um plano por: + +```text +tipo raiz + lista ordinal de colunas +``` + +O plano pre-computa: + +- resolucao de mapping por coluna; +- arvore de `MemberPath`; +- delegates de getter/setter; +- factories de construtores sem parametros; +- indices de colunas por subarvore para decidir `NULL` total/parcial. + +Por linha, o materializer executa leitura do valor, conversao leve e chamada dos delegates cacheados. Nao ha busca ampla de reflection por coluna por linha. + +## AOT And Trimming + +`QueryMapped*` e um caminho runtime/reflection-based e compila delegates em tempo de execucao. Por isso, as APIs foram anotadas com: + +- `RequiresUnreferencedCode`; +- `RequiresDynamicCode`. + +O source generator da Etapa 4 continua limitado a registro de mappings. Um materializer gerado permanece a estrategia futura preferencial para consumidores trimmed/Native AOT. + +## Diagnostics + +`MemberMappingExplanation` recebeu a propriedade: + +```csharp +MappingMaterialization Materialization +``` + +Valores: + +- `Dapper` para mappings raiz e fallback tradicional; +- `Nested` para paths aninhados materializados pelo wrapper opt-in. + +Consumidores existentes nao quebram porque a API de diagnostico foi ampliada sem remover membros existentes. + +## Delivery + +Arquivos adicionados: + +- `src/Dapper.FluentMap/Diagnostics/MappingMaterialization.cs` +- `src/Dapper.FluentMap/Materialization/MaterializationPlanCacheKey.cs` +- `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs` +- `src/Dapper.FluentMap/QueryMappedExtensions.cs` +- `test/Dapper.FluentMap.Tests/NestedObjectMaterializationTests.cs` +- `docs/sdd/etapa-5/02-nested-object-materialization.md` + +Arquivos alterados: + +- `README.md` +- `src/Dapper.FluentMap/Compatibility/CodeAnalysisAttributes.cs` +- `src/Dapper.FluentMap/Diagnostics/MemberMappingExplanation.cs` +- `src/Dapper.FluentMap/MappingConfigurationValidator.cs` +- `src/Dapper.FluentMap/MappingRegistry.cs` +- `test/Dapper.FluentMap.Tests/NestedMaterializationSpikeTests.cs` +- `docs/sdd/etapa-5/decisions.md` +- `docs/sdd/etapa-5/status.md` + +## Validation + +Validacao localizada executada durante a implementacao: + +```text +dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~NestedObjectMaterializationTests" +``` + +Resultado: + +```text +16 testes aprovados +``` + +Validacao relacionada: + +```text +dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~NestedMaterializationSpikeTests|FullyQualifiedName~NestedObjectMaterializationTests|FullyQualifiedName~DiagnosticsApiTests|FullyQualifiedName~ConstructorMappingTests" +``` + +Resultado: + +```text +43 testes aprovados +``` + +Validacao final completa deve registrar: + +```text +dotnet restore +dotnet build +dotnet test +dotnet build --configuration Release +dotnet test --configuration Release +``` + +Resultado final: + +- `dotnet restore`: sucesso; +- `dotnet build`: sucesso, 0 warnings, 0 erros; +- `dotnet test`: sucesso, 150 testes do core, 7 Dommel, 7 analyzer, 12 generator e 1 generated-registration integration; +- `dotnet build --configuration Release`: sucesso, 0 warnings, 0 erros; +- `dotnet test --configuration Release`: sucesso com os mesmos 177 testes. + +Smokes AOT/trimming: + +- `dotnet run --project .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release`: `explicit:ok`; +- `dotnet run --project .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:DefineConstants=AOT_SMOKE_SCANNING`: `scanning:ok`; +- `dotnet run --project .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:DefineConstants=AOT_SMOKE_GENERATED`: `generated:ok`; +- publish trimmed explicit: sucesso, runtime `explicit:ok`, sem warnings FluentMap-owned; warnings restantes pertencem ao Dapper; +- publish trimmed generated: sucesso, runtime `generated:ok`, sem warnings FluentMap-owned; warnings restantes pertencem ao Dapper; +- publish Native AOT explicit: falhou no ambiente com `Platform linker not found`; runtime Native AOT nao foi validado. + +## Limitations For Delivery 3 + +- Value Objects imutaveis aninhados devem definir construcao por TypeHandler, construtor, factory ou materializer gerado. +- Nested records exigem plano de construtor em vez de setters. +- NRT metadata nao foi usada para diferenciar intermediarios nullable/non-nullable. +- O caminho runtime nao e a estrategia ideal para Native AOT. +- Conversoes cobrem escalares comuns e fallback settable; conversoes complexas devem ser tratadas por entrega dedicada. + +## Semantic Commit + +Mensagem: + +```text +feat: support nested object mappings +``` diff --git a/docs/sdd/etapa-5/decisions.md b/docs/sdd/etapa-5/decisions.md index 2f63760..204a439 100644 --- a/docs/sdd/etapa-5/decisions.md +++ b/docs/sdd/etapa-5/decisions.md @@ -6,9 +6,13 @@ Registre aqui apenas decisoes arquiteturais necessarias as proximas entregas. - `MemberPath` continua sendo identidade e diagnostico de caminho; ele nao deve ser entregue diretamente ao Dapper como `PropertyInfo` terminal para simular nested assignment. - `Dapper.Query` com o `ITypeMap` atual do Dapper permanece suportado para mappings simples, constructor mapping simples, conventions, naming policies e fallback. -- Nested object materialization deve ser opt-in por um caminho controlado pelo FluentMap, provavelmente uma API paralela de consulta/materializacao como `QueryMapped`. -- O caminho opt-in deve ler os valores do reader ou de uma representacao intermediaria e aplicar um plano de materializacao baseado em `MemberPath`. -- A Entrega 2 deve impedir que nested paths sejam tratados como propriedades simples pelo type map instalado no Dapper, porque isso pode escrever o valor do leaf no slot errado do objeto raiz. +- Nested object materialization e opt-in por uma API paralela de consulta/materializacao: `QueryMapped` e `QueryMappedSingle`. +- O caminho opt-in le valores do reader e aplica um plano de materializacao baseado em `MemberPath`. +- Nested paths nao sao tratados como propriedades simples pelo type map instalado no Dapper, porque isso pode escrever o valor do leaf no slot errado do objeto raiz. +- A Entrega 2 suporta objetos aninhados mutaveis com construtor publico sem parametros e propriedades publicamente settable. +- A semantica de `NULL` e por subarvore: quando todos os valores nested de uma subarvore sao `NULL`, o intermediario fica `null`; quando algum valor nao e `NULL`, o intermediario e criado ou reutilizado. +- `Explain()` representa nested mappings com `Materialization = Nested`. +- Prefix conflicts como `Address` e `Address.City` no mesmo plano sao rejeitados. ## Value Objects @@ -25,4 +29,4 @@ Registre aqui apenas decisoes arquiteturais necessarias as proximas entregas. - O generator da Etapa 4 continua limitado a registro de mappings. - Um materializer gerado pode ser uma estrategia futura para performance, trimming e Native AOT, mas nao deve ser acoplado a Entrega 2 como unico caminho. -- O caminho runtime/reflection-based deve ser documentado como menos AOT-friendly; o caminho gerado deve ser a opcao preferencial para consumidores trimmed/AOT quando existir. +- O caminho runtime/reflection-based de `QueryMapped*` e documentado como menos AOT-friendly e foi anotado com `RequiresUnreferencedCode` e `RequiresDynamicCode`; o caminho gerado deve ser a opcao preferencial para consumidores trimmed/AOT quando existir. diff --git a/docs/sdd/etapa-5/status.md b/docs/sdd/etapa-5/status.md index 1dac37a..72fa7d7 100644 --- a/docs/sdd/etapa-5/status.md +++ b/docs/sdd/etapa-5/status.md @@ -1,6 +1,6 @@ | Entrega | Status | Commit | |---|---|---| | 01 - Spike nested/value-object | Concluido | - | -| 02 - Nested object materialization | Pendente | - | +| 02 - Nested object materialization | Concluido | - | | 03 - Value Objects imutaveis | Pendente | - | | 04 - Mapping profiles | Pendente | - | diff --git a/src/Dapper.FluentMap/Compatibility/CodeAnalysisAttributes.cs b/src/Dapper.FluentMap/Compatibility/CodeAnalysisAttributes.cs index cda0369..5e587b1 100644 --- a/src/Dapper.FluentMap/Compatibility/CodeAnalysisAttributes.cs +++ b/src/Dapper.FluentMap/Compatibility/CodeAnalysisAttributes.cs @@ -57,5 +57,22 @@ public RequiresUnreferencedCodeAttribute(string message) public string Url { get; set; } } + + [AttributeUsage( + AttributeTargets.Constructor | + AttributeTargets.Method | + AttributeTargets.Class, + Inherited = false)] + internal sealed class RequiresDynamicCodeAttribute : Attribute + { + public RequiresDynamicCodeAttribute(string message) + { + Message = message; + } + + public string Message { get; } + + public string Url { get; set; } + } } #endif diff --git a/src/Dapper.FluentMap/Diagnostics/MappingMaterialization.cs b/src/Dapper.FluentMap/Diagnostics/MappingMaterialization.cs new file mode 100644 index 0000000..e0914ea --- /dev/null +++ b/src/Dapper.FluentMap/Diagnostics/MappingMaterialization.cs @@ -0,0 +1,18 @@ +namespace Dapper.FluentMap.Diagnostics +{ + /// + /// Describes how a mapped member is materialized. + /// + public enum MappingMaterialization + { + /// + /// The member is materialized by Dapper's regular root-object mapping. + /// + Dapper, + + /// + /// The member is materialized by FluentMap's opt-in nested object materializer. + /// + Nested + } +} diff --git a/src/Dapper.FluentMap/Diagnostics/MemberMappingExplanation.cs b/src/Dapper.FluentMap/Diagnostics/MemberMappingExplanation.cs index 8856e7d..3a5d4d6 100644 --- a/src/Dapper.FluentMap/Diagnostics/MemberMappingExplanation.cs +++ b/src/Dapper.FluentMap/Diagnostics/MemberMappingExplanation.cs @@ -20,7 +20,8 @@ internal MemberMappingExplanation( bool ignored, Type inheritedFrom, Type conventionType, - IEnumerable constructorParameters) + IEnumerable constructorParameters, + MappingMaterialization materialization) { if (string.IsNullOrEmpty(memberPath)) { @@ -42,6 +43,7 @@ internal MemberMappingExplanation( ConventionType = conventionType; ConstructorParameters = new ReadOnlyCollection( (constructorParameters ?? Enumerable.Empty()).ToList()); + Materialization = materialization; } /// @@ -88,5 +90,10 @@ internal MemberMappingExplanation( /// Gets constructor parameters that can receive this mapped column. /// public IReadOnlyList ConstructorParameters { get; } + + /// + /// Gets how this member is materialized. + /// + public MappingMaterialization Materialization { get; } } } diff --git a/src/Dapper.FluentMap/MappingConfigurationValidator.cs b/src/Dapper.FluentMap/MappingConfigurationValidator.cs index 55ce011..480c1de 100644 --- a/src/Dapper.FluentMap/MappingConfigurationValidator.cs +++ b/src/Dapper.FluentMap/MappingConfigurationValidator.cs @@ -1,6 +1,8 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Linq; +using System.Reflection; using Dapper.FluentMap.Conventions; using Dapper.FluentMap.Mapping; @@ -23,6 +25,7 @@ internal static void ValidateEntityMap(Type entityType, IEntityMap entityMap) var maps = GetEntityMapDescriptors(entityType, entityMap).ToList(); ValidateDuplicateMemberPaths(entityType, maps, "entity map", entityMap.GetType()); ValidateColumnConflicts(entityType, maps, "entity map", entityMap.GetType()); + ValidateNestedMaterializationPaths(entityType, maps, "entity map", entityMap.GetType()); } internal static void ValidateComposedEntityMap(Type entityType, IEntityMap entityMap, IList propertyMaps) @@ -44,6 +47,7 @@ internal static void ValidateComposedEntityMap(Type entityType, IEntityMap entit var maps = GetEntityMapDescriptors(entityType, propertyMaps, entityMap.GetType(), "composed entity map").ToList(); ValidateColumnConflicts(entityType, maps, "composed entity map", entityMap.GetType()); + ValidateNestedMaterializationPaths(entityType, maps, "composed entity map", entityMap.GetType()); } internal static void ValidateConvention(Type entityType, Convention convention) @@ -213,6 +217,126 @@ private static bool ShouldValidateColumnConflict(IPropertyMap left, IPropertyMap right.GetType() == typeof(PropertyMap); } + private static void ValidateNestedMaterializationPaths(Type entityType, IList maps, string sourceKind, Type sourceType) + { + var activeMaps = maps + .Where(map => !map.Map.Ignored) + .ToList(); + + foreach (var map in activeMaps.Where(map => map.MemberPath.IsNested)) + { + ValidateNestedMaterializationPath(entityType, map.MemberPath, sourceKind, sourceType); + } + + for (var i = 0; i < activeMaps.Count; i++) + { + for (var j = i + 1; j < activeMaps.Count; j++) + { + if (!IsPathPrefix(activeMaps[i].MemberPath, activeMaps[j].MemberPath) && + !IsPathPrefix(activeMaps[j].MemberPath, activeMaps[i].MemberPath)) + { + continue; + } + + throw new FluentMapConfigurationException( + $"Property path '{activeMaps[i].MemberPath}' conflicts with property path '{activeMaps[j].MemberPath}' for entity '{FormatType(entityType)}' in {sourceKind} '{FormatType(sourceType)}'. Nested materialization cannot map both a path and one of its descendants."); + } + } + } + + private static void ValidateNestedMaterializationPath(Type entityType, MemberPath memberPath, string sourceKind, Type sourceType) + { + var properties = memberPath.Properties; + + for (var i = 0; i < properties.Count; i++) + { + var property = properties[i]; + if (property.GetIndexParameters().Length != 0) + { + throw UnsupportedNestedPath(entityType, memberPath, sourceKind, sourceType, $"Property '{property.Name}' is an indexer."); + } + + if (IsStatic(property)) + { + throw UnsupportedNestedPath(entityType, memberPath, sourceKind, sourceType, $"Property '{property.Name}' is static."); + } + + if (!CanRead(property)) + { + throw UnsupportedNestedPath(entityType, memberPath, sourceKind, sourceType, $"Property '{property.Name}' must have a public getter."); + } + + if (!CanWrite(property)) + { + throw UnsupportedNestedPath(entityType, memberPath, sourceKind, sourceType, $"Property '{property.Name}' must be settable."); + } + + if (i == properties.Count - 1) + { + continue; + } + + var propertyType = property.PropertyType; + if (IsUnsupportedIntermediateType(propertyType)) + { + throw UnsupportedNestedPath(entityType, memberPath, sourceKind, sourceType, $"Intermediate property '{property.Name}' has unsupported type '{FormatType(propertyType)}'. Collections and scalar values cannot appear in the middle of a nested path."); + } + } + } + + private static bool IsPathPrefix(MemberPath prefix, MemberPath path) + { + if (prefix.Properties.Count >= path.Properties.Count) + { + return false; + } + + for (var i = 0; i < prefix.Properties.Count; i++) + { + if (!Equals(prefix.Properties[i], path.Properties[i])) + { + return false; + } + } + + return true; + } + + private static bool CanRead(PropertyInfo property) + { + var getter = property.GetGetMethod(); + return getter != null && !getter.IsStatic; + } + + private static bool CanWrite(PropertyInfo property) + { + var setter = property.GetSetMethod(); + return setter != null && !setter.IsStatic; + } + + private static bool IsStatic(PropertyInfo property) + { + var getter = property.GetGetMethod(); + var setter = property.GetSetMethod(); + return (getter != null && getter.IsStatic) || (setter != null && setter.IsStatic); + } + + private static bool IsUnsupportedIntermediateType(Type type) + { + if (!type.IsClass || type == typeof(string)) + { + return true; + } + + return typeof(IEnumerable).IsAssignableFrom(type); + } + + private static FluentMapConfigurationException UnsupportedNestedPath(Type entityType, MemberPath memberPath, string sourceKind, Type sourceType, string reason) + { + return new FluentMapConfigurationException( + $"Property path '{memberPath}' is not supported for nested materialization on entity '{FormatType(entityType)}' in {sourceKind} '{FormatType(sourceType)}'. {reason}"); + } + private static bool IsMapForEntity(Type entityType, IPropertyMap map) { #if NETSTANDARD1_3 diff --git a/src/Dapper.FluentMap/MappingRegistry.cs b/src/Dapper.FluentMap/MappingRegistry.cs index ebc7f48..12e87d3 100644 --- a/src/Dapper.FluentMap/MappingRegistry.cs +++ b/src/Dapper.FluentMap/MappingRegistry.cs @@ -8,6 +8,7 @@ using Dapper.FluentMap.Conventions; using Dapper.FluentMap.Diagnostics; using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.Materialization; using Dapper.FluentMap.TypeMaps; namespace Dapper.FluentMap @@ -17,6 +18,9 @@ internal sealed class MappingRegistry private readonly ConcurrentDictionary _propertyMapCache = new ConcurrentDictionary(); + private readonly ConcurrentDictionary _materializationPlanCache = + new ConcurrentDictionary(); + internal ConcurrentDictionary EntityMaps { get; } = new ConcurrentDictionary(); @@ -25,6 +29,8 @@ internal sealed class MappingRegistry internal int CacheEntryCount => _propertyMapCache.Count; + internal int MaterializationPlanCacheEntryCount => _materializationPlanCache.Count; + internal void AddEntityMap(IEntityMap mapper) where TEntity : class { @@ -131,6 +137,24 @@ internal IPropertyMap GetConventionPropertyMap(Type type, string columnName) .PropertyMap; } + internal NestedMaterializationPlan GetMaterializationPlan(Type type, string[] columnNames) + { + if (type == null) + { + throw new ArgumentNullException(nameof(type)); + } + + if (columnNames == null) + { + throw new ArgumentNullException(nameof(columnNames)); + } + + var cacheKey = new MaterializationPlanCacheKey(type, columnNames); + return _materializationPlanCache.GetOrAdd( + cacheKey, + key => NestedMaterializationPlan.Create(key.Type, key.ColumnNames, this)); + } + internal void ValidateConfiguration() { var errors = new List(); @@ -235,6 +259,7 @@ internal void Reset(params Type[] dapperTypes) EntityMaps.Clear(); TypeConventions.Clear(); _propertyMapCache.Clear(); + _materializationPlanCache.Clear(); if (dapperTypes == null) { @@ -259,6 +284,11 @@ private void InvalidateType(Type type) { _propertyMapCache.TryRemove(key, out _); } + + foreach (var key in _materializationPlanCache.Keys.Where(k => k.Type == type)) + { + _materializationPlanCache.TryRemove(key, out _); + } } private IPropertyMap ResolveFluentPropertyMap(Type type, string columnName) @@ -420,7 +450,8 @@ private void AddDapperDefaultExplanations( ignored: false, inheritedFrom: null, conventionType: null, - constructorParameters: constructorParameters)); + constructorParameters: constructorParameters, + materialization: MappingMaterialization.Dapper)); configuredPaths.Add(memberPath); } } @@ -436,6 +467,9 @@ private void AddMemberExplanation( var constructorParameters = descriptor.Map.Ignored || memberPath.IsNested ? new ConstructorParameterExplanation[0] : GetConstructorParameters(entityType, descriptor.Map.PropertyInfo); + var materialization = memberPath.IsNested + ? MappingMaterialization.Nested + : MappingMaterialization.Dapper; members.Add(new MemberMappingExplanation( memberPath.ToString(), @@ -446,7 +480,8 @@ private void AddMemberExplanation( descriptor.Map.Ignored, descriptor.InheritedFrom, descriptor.ConventionType, - constructorParameters)); + constructorParameters, + materialization)); configuredPaths.Add(memberPath); } @@ -545,7 +580,14 @@ internal MappingCacheEntry(IPropertyMap propertyMap) if (!propertyMap.Ignored) { - PropertyInfo = propertyMap.PropertyInfo; + var memberPath = PropertyMapIdentity.GetMemberPath(propertyMap); + PropertyInfo = memberPath.IsNested +#if !NETSTANDARD1_3 + ? new IgnoredPropertyInfo() +#else + ? null +#endif + : propertyMap.PropertyInfo; return; } diff --git a/src/Dapper.FluentMap/Materialization/MaterializationPlanCacheKey.cs b/src/Dapper.FluentMap/Materialization/MaterializationPlanCacheKey.cs new file mode 100644 index 0000000..cafe2c5 --- /dev/null +++ b/src/Dapper.FluentMap/Materialization/MaterializationPlanCacheKey.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; + +namespace Dapper.FluentMap.Materialization +{ + internal sealed class MaterializationPlanCacheKey : IEquatable + { + private readonly string[] _columnNames; + private readonly int _hashCode; + + internal MaterializationPlanCacheKey(Type type, IEnumerable columnNames) + { + if (type == null) + { + throw new ArgumentNullException(nameof(type)); + } + + if (columnNames == null) + { + throw new ArgumentNullException(nameof(columnNames)); + } + + Type = type; + _columnNames = columnNames.ToArray(); + ColumnNames = new ReadOnlyCollection(_columnNames); + _hashCode = CalculateHashCode(type, _columnNames); + } + + internal Type Type { get; } + + internal IReadOnlyList ColumnNames { get; } + + public bool Equals(MaterializationPlanCacheKey other) + { + if (ReferenceEquals(this, other)) + { + return true; + } + + if (other == null || Type != other.Type || _columnNames.Length != other._columnNames.Length) + { + return false; + } + + for (var i = 0; i < _columnNames.Length; i++) + { + if (!string.Equals(_columnNames[i], other._columnNames[i], StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + + public override bool Equals(object obj) + { + return obj is MaterializationPlanCacheKey other && Equals(other); + } + + public override int GetHashCode() + { + return _hashCode; + } + + private static int CalculateHashCode(Type type, string[] columnNames) + { + unchecked + { + var hash = type.GetHashCode(); + foreach (var columnName in columnNames) + { + hash = (hash * 31) + (columnName == null ? 0 : StringComparer.Ordinal.GetHashCode(columnName)); + } + + return hash; + } + } + } +} diff --git a/src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs b/src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs new file mode 100644 index 0000000..ad94108 --- /dev/null +++ b/src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs @@ -0,0 +1,341 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Globalization; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using Dapper.FluentMap.Mapping; + +namespace Dapper.FluentMap.Materialization +{ + internal sealed class NestedMaterializationPlan + { + private readonly Func _entityFactory; + private readonly Assignment[] _rootAssignments; + private readonly NestedNode[] _nestedNodes; + + private NestedMaterializationPlan( + Func entityFactory, + IEnumerable rootAssignments, + IEnumerable nestedNodes) + { + _entityFactory = entityFactory; + _rootAssignments = rootAssignments.ToArray(); + _nestedNodes = nestedNodes.ToArray(); + } + + internal static NestedMaterializationPlan Create(Type entityType, IReadOnlyList columnNames, MappingRegistry registry) + { + if (entityType == null) + { + throw new ArgumentNullException(nameof(entityType)); + } + + if (columnNames == null) + { + throw new ArgumentNullException(nameof(columnNames)); + } + + if (registry == null) + { + throw new ArgumentNullException(nameof(registry)); + } + + var entityFactory = CreateFactory(entityType, $"Entity type '{FormatType(entityType)}' must have a public parameterless constructor to use QueryMapped when nested mappings are present."); + var defaultTypeMap = new DefaultTypeMap(entityType); + var rootAssignments = new List(); + var nestedNodes = new List(); + + for (var i = 0; i < columnNames.Count; i++) + { + var columnName = columnNames[i]; + var fluentMap = registry.GetFluentPropertyMap(entityType, columnName); + if (fluentMap != null) + { + if (fluentMap.Ignored) + { + continue; + } + + var memberPath = PropertyMapIdentity.GetMemberPath(fluentMap); + if (memberPath.IsNested) + { + AddNestedAssignment(nestedNodes, memberPath, i); + continue; + } + + rootAssignments.Add(Assignment.ForProperty(i, memberPath.PropertyInfo)); + continue; + } + + var defaultMember = defaultTypeMap.GetMember(columnName); + if (defaultMember == null) + { + continue; + } + + if (defaultMember.Property != null) + { + rootAssignments.Add(Assignment.ForProperty(i, defaultMember.Property)); + } + else if (defaultMember.Field != null) + { + rootAssignments.Add(Assignment.ForField(i, defaultMember.Field)); + } + } + + foreach (var node in nestedNodes) + { + node.Seal(); + } + + return new NestedMaterializationPlan(entityFactory, rootAssignments, nestedNodes); + } + + internal object Materialize(IDataRecord record) + { + var entity = _entityFactory(); + + foreach (var assignment in _rootAssignments) + { + assignment.Assign(entity, record); + } + + foreach (var node in _nestedNodes) + { + node.Apply(entity, record); + } + + return entity; + } + + private static void AddNestedAssignment(IList rootNodes, MemberPath memberPath, int columnIndex) + { + var properties = memberPath.Properties; + var nodes = rootNodes; + var node = default(NestedNode); + + for (var i = 0; i < properties.Count - 1; i++) + { + node = FindOrAddNode(nodes, properties[i]); + nodes = node.Children; + } + + node.Leaves.Add(Assignment.ForProperty(columnIndex, properties[properties.Count - 1])); + } + + private static NestedNode FindOrAddNode(IList nodes, PropertyInfo property) + { + var node = nodes.FirstOrDefault(n => Equals(n.Property, property)); + if (node != null) + { + return node; + } + + node = new NestedNode(property); + nodes.Add(node); + return node; + } + + private static Func CreateFactory(Type type, string errorMessage) + { + var constructor = type.GetConstructor(Type.EmptyTypes); + if (constructor == null) + { + throw new FluentMapConfigurationException(errorMessage); + } + + var body = Expression.Convert(Expression.New(constructor), typeof(object)); + return Expression.Lambda>(body).Compile(); + } + + private static Func CreateGetter(PropertyInfo property) + { + var target = Expression.Parameter(typeof(object), "target"); + var body = Expression.Convert( + Expression.Property(Expression.Convert(target, property.DeclaringType), property), + typeof(object)); + + return Expression.Lambda>(body, target).Compile(); + } + + private static Action CreatePropertySetter(PropertyInfo property) + { + var target = Expression.Parameter(typeof(object), "target"); + var value = Expression.Parameter(typeof(object), "value"); + var body = Expression.Assign( + Expression.Property(Expression.Convert(target, property.DeclaringType), property), + Expression.Convert(value, property.PropertyType)); + + return Expression.Lambda>(body, target, value).Compile(); + } + + private static Action CreateFieldSetter(FieldInfo field) + { + var target = Expression.Parameter(typeof(object), "target"); + var value = Expression.Parameter(typeof(object), "value"); + var body = Expression.Assign( + Expression.Field(Expression.Convert(target, field.DeclaringType), field), + Expression.Convert(value, field.FieldType)); + + return Expression.Lambda>(body, target, value).Compile(); + } + + private static object ConvertValue(object value, Type targetType) + { + if (value == null || value == DBNull.Value) + { + return GetDefaultValue(targetType); + } + + var conversionType = Nullable.GetUnderlyingType(targetType) ?? targetType; + if (conversionType.IsInstanceOfType(value)) + { + return value; + } + + if (conversionType.GetTypeInfo().IsEnum) + { + return value is string text + ? Enum.Parse(conversionType, text) + : Enum.ToObject(conversionType, value); + } + + if (conversionType == typeof(Guid) && value is string guidText) + { + return new Guid(guidText); + } + + return Convert.ChangeType(value, conversionType, CultureInfo.InvariantCulture); + } + + private static object GetDefaultValue(Type type) + { + if (!type.GetTypeInfo().IsValueType || Nullable.GetUnderlyingType(type) != null) + { + return null; + } + + return Activator.CreateInstance(type); + } + + private static bool CanAssignNull(Type type) + { + return !type.GetTypeInfo().IsValueType || Nullable.GetUnderlyingType(type) != null; + } + + private static bool HasNonNullValue(IDataRecord record, IEnumerable columnIndexes) + { + return columnIndexes.Any(index => !record.IsDBNull(index)); + } + + private static string FormatType(Type type) + { + return type == null ? "" : type.FullName; + } + + private sealed class NestedNode + { + private int[] _subtreeColumnIndexes; + + internal NestedNode(PropertyInfo property) + { + Property = property; + Children = new List(); + Leaves = new List(); + Getter = CreateGetter(property); + Setter = CreatePropertySetter(property); + Factory = CreateFactory( + property.PropertyType, + $"Intermediate property '{property.Name}' of type '{FormatType(property.PropertyType)}' must have a public parameterless constructor for nested materialization."); + } + + internal PropertyInfo Property { get; } + + internal IList Children { get; } + + internal IList Leaves { get; } + + private Func Getter { get; } + + private Action Setter { get; } + + private Func Factory { get; } + + internal void Seal() + { + foreach (var child in Children) + { + child.Seal(); + } + + _subtreeColumnIndexes = Leaves + .Select(leaf => leaf.ColumnIndex) + .Concat(Children.SelectMany(child => child._subtreeColumnIndexes)) + .Distinct() + .ToArray(); + } + + internal void Apply(object parent, IDataRecord record) + { + if (!HasNonNullValue(record, _subtreeColumnIndexes)) + { + if (CanAssignNull(Property.PropertyType)) + { + Setter(parent, null); + } + + return; + } + + var current = Getter(parent); + if (current == null) + { + current = Factory(); + Setter(parent, current); + } + + foreach (var child in Children) + { + child.Apply(current, record); + } + + foreach (var leaf in Leaves) + { + leaf.Assign(current, record); + } + } + } + + private sealed class Assignment + { + private readonly Type _targetType; + private readonly Action _setter; + + private Assignment(int columnIndex, Type targetType, Action setter) + { + ColumnIndex = columnIndex; + _targetType = targetType; + _setter = setter; + } + + internal int ColumnIndex { get; } + + internal static Assignment ForProperty(int columnIndex, PropertyInfo property) + { + return new Assignment(columnIndex, property.PropertyType, CreatePropertySetter(property)); + } + + internal static Assignment ForField(int columnIndex, FieldInfo field) + { + return new Assignment(columnIndex, field.FieldType, CreateFieldSetter(field)); + } + + internal void Assign(object target, IDataRecord record) + { + _setter(target, ConvertValue(record.GetValue(ColumnIndex), _targetType)); + } + } + } +} diff --git a/src/Dapper.FluentMap/QueryMappedExtensions.cs b/src/Dapper.FluentMap/QueryMappedExtensions.cs new file mode 100644 index 0000000..c2b8bf4 --- /dev/null +++ b/src/Dapper.FluentMap/QueryMappedExtensions.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Dapper.FluentMap.Materialization; + +namespace Dapper.FluentMap +{ + /// + /// Provides opt-in query helpers for FluentMap-controlled materialization. + /// + public static class QueryMappedExtensions + { + private const DynamicallyAccessedMemberTypes MaterializedEntityMemberTypes = + DynamicallyAccessedMemberTypes.PublicParameterlessConstructor | + DynamicallyAccessedMemberTypes.PublicProperties; + + private const string QueryMappedRequiresUnreferencedCodeMessage = + "QueryMapped uses runtime mapping metadata to materialize nested objects. Prefer generated materializers when publishing trimmed or Native AOT applications."; + + private const string QueryMappedRequiresDynamicCodeMessage = + "QueryMapped compiles runtime accessors for nested object materialization. Prefer generated materializers when publishing Native AOT applications."; + + /// + /// Executes a query and materializes rows using FluentMap's opt-in nested object materializer. + /// + /// The entity type to materialize. + /// The database connection. + /// The SQL query to execute. + /// Optional query parameters. + /// Optional transaction. + /// Optional command timeout. + /// Optional command type. + /// The materialized rows. + [RequiresUnreferencedCode(QueryMappedRequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedRequiresDynamicCodeMessage)] + public static IEnumerable QueryMapped< + [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] + TEntity>( + this IDbConnection connection, + string sql, + object param = null, + IDbTransaction transaction = null, + int? commandTimeout = null, + CommandType? commandType = null) + where TEntity : class + { + if (connection == null) + { + throw new ArgumentNullException(nameof(connection)); + } + + if (sql == null) + { + throw new ArgumentNullException(nameof(sql)); + } + + using (var reader = SqlMapper.ExecuteReader(connection, sql, param, transaction, commandTimeout, commandType)) + { + var columnNames = GetColumnNames(reader); + var plan = FluentMapper.Registry.GetMaterializationPlan(typeof(TEntity), columnNames); + var results = new List(); + + while (reader.Read()) + { + results.Add((TEntity)plan.Materialize(reader)); + } + + return results; + } + } + + /// + /// Executes a query and materializes exactly one row using FluentMap's opt-in nested object materializer. + /// + /// The entity type to materialize. + /// The database connection. + /// The SQL query to execute. + /// Optional query parameters. + /// Optional transaction. + /// Optional command timeout. + /// Optional command type. + /// The materialized row. + [RequiresUnreferencedCode(QueryMappedRequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedRequiresDynamicCodeMessage)] + public static TEntity QueryMappedSingle< + [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] + TEntity>( + this IDbConnection connection, + string sql, + object param = null, + IDbTransaction transaction = null, + int? commandTimeout = null, + CommandType? commandType = null) + where TEntity : class + { + return QueryMapped(connection, sql, param, transaction, commandTimeout, commandType).Single(); + } + + private static string[] GetColumnNames(IDataRecord reader) + { + var columnNames = new string[reader.FieldCount]; + for (var i = 0; i < columnNames.Length; i++) + { + columnNames[i] = reader.GetName(i); + } + + return columnNames; + } + } +} diff --git a/test/Dapper.FluentMap.Tests/InheritedMappingTests.cs b/test/Dapper.FluentMap.Tests/InheritedMappingTests.cs index dc8bca7..811c5b8 100644 --- a/test/Dapper.FluentMap.Tests/InheritedMappingTests.cs +++ b/test/Dapper.FluentMap.Tests/InheritedMappingTests.cs @@ -100,9 +100,14 @@ public void IncludedBaseMappingShouldPreserveInheritedMemberPath() }); var member = SqlMapper.GetTypeMap(typeof(MemberPathAdminUser)).GetMember("rank_level"); - - Assert.NotNull(member); - Assert.Equal(typeof(InheritedRankInfo).GetProperty(nameof(InheritedRankInfo.Level)), member.Property); + var explanation = FluentMapper.Explain(); + + Assert.Null(member); + Assert.Contains( + explanation.Members, + m => m.MemberPath == "Rank.Level" && + m.ColumnName == "rank_level" && + m.PropertyInfo == typeof(InheritedRankInfo).GetProperty(nameof(InheritedRankInfo.Level))); } [Fact] diff --git a/test/Dapper.FluentMap.Tests/NestedMaterializationSpikeTests.cs b/test/Dapper.FluentMap.Tests/NestedMaterializationSpikeTests.cs index 227d0e8..27b83ab 100644 --- a/test/Dapper.FluentMap.Tests/NestedMaterializationSpikeTests.cs +++ b/test/Dapper.FluentMap.Tests/NestedMaterializationSpikeTests.cs @@ -11,7 +11,7 @@ public class NestedMaterializationSpikeTests { [Fact] [Trait("Category", "Integration")] - public void NestedMutablePathShouldWriteLeafValueIntoRootSlotInsteadOfMaterializingGraph() + public void DapperQueryShouldNotTreatNestedMutablePathAsRootProperty() { PreTest(typeof(NestedMutableCustomer)); @@ -23,10 +23,7 @@ public void NestedMutablePathShouldWriteLeafValueIntoRootSlotInsteadOfMaterializ { var customer = connection.QuerySingle("SELECT 'Recife' AS city;"); - var assignedValue = (object)customer.Address; - - Assert.IsType(assignedValue); - Assert.Equal("Recife", assignedValue); + Assert.Null(customer.Address); } } finally @@ -37,7 +34,7 @@ public void NestedMutablePathShouldWriteLeafValueIntoRootSlotInsteadOfMaterializ [Fact] [Trait("Category", "Integration")] - public void NestedPathsWithSameTerminalShouldBeConfiguredButDapperStillReceivesOnlyTerminalMembers() + public void NestedPathsWithSameTerminalShouldBeConfiguredButDapperQueryShouldNotMaterializeThem() { PreTest(typeof(SameTerminalCustomer)); @@ -54,7 +51,7 @@ public void NestedPathsWithSameTerminalShouldBeConfiguredButDapperStillReceivesO var customer = connection.QuerySingle( "SELECT 'gold' AS rank_level, 'staff' AS seniority_level;"); - Assert.Equal("staff", (object)customer.Rank); + Assert.Null(customer.Rank); Assert.Null(customer.Seniority); } } @@ -100,16 +97,12 @@ public void TypeHandlerShouldNotMaterializeNestedValueObjectPath() try { SqlMapper.AddTypeHandler(new CpfTypeHandler()); - FluentMapper.Initialize(c => c.AddMap(new NestedValueObjectCustomerMap())); - using (var connection = OpenConnection()) - { - var exception = Assert.ThrowsAny(() => - connection.QuerySingle( - "SELECT '12345678909' AS cpf;")); + var exception = Assert.Throws( + () => FluentMapper.Initialize(c => c.AddMap(new NestedValueObjectCustomerMap()))); - Assert.Contains("Number", exception.ToString(), StringComparison.Ordinal); - } + Assert.Contains("Cpf.Number", exception.Message, StringComparison.Ordinal); + Assert.Contains("settable", exception.Message, StringComparison.OrdinalIgnoreCase); } finally { diff --git a/test/Dapper.FluentMap.Tests/NestedObjectMaterializationTests.cs b/test/Dapper.FluentMap.Tests/NestedObjectMaterializationTests.cs new file mode 100644 index 0000000..5c26c2d --- /dev/null +++ b/test/Dapper.FluentMap.Tests/NestedObjectMaterializationTests.cs @@ -0,0 +1,677 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Dapper.FluentMap.Diagnostics; +using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.Naming; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class NestedObjectMaterializationTests + { + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldMaterializeSimpleNestedObject() + { + PreTest(typeof(Customer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 7 AS customer_id, 'Sao Paulo' AS city;"); + + Assert.Equal(7, customer.Id); + Assert.NotNull(customer.Address); + Assert.Equal("Sao Paulo", customer.Address.City); + } + } + finally + { + PreTest(typeof(Customer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldMaterializeThreeLevelNestedObject() + { + PreTest(typeof(CustomerWithCountry)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerWithCountryMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 'Brazil' AS country_name;"); + + Assert.NotNull(customer.Address); + Assert.NotNull(customer.Address.Country); + Assert.Equal("Brazil", customer.Address.Country.Name); + } + } + finally + { + PreTest(typeof(CustomerWithCountry)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldPreserveSameTerminalMemberPaths() + { + PreTest(typeof(SameTerminalCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new SameTerminalCustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 10 AS rank_level, 20 AS seniority_level;"); + + Assert.NotNull(customer.Rank); + Assert.NotNull(customer.Seniority); + Assert.Equal(10, customer.Rank.Level); + Assert.Equal(20, customer.Seniority.Level); + } + } + finally + { + PreTest(typeof(SameTerminalCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldApplyNamingPolicyToRootPropertiesAndExplicitNestedMappings() + { + PreTest(typeof(PolicyCustomer)); + + try + { + FluentMapper.Initialize(c => + { + c.UseNamingPolicy(NamingPolicy.SnakeCase, caseSensitive: false).ForEntity(); + c.AddMap(new PolicyCustomerMap()); + }); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 42 AS CUSTOMER_ID, 'Campinas' AS city;"); + + Assert.Equal(42, customer.CustomerId); + Assert.NotNull(customer.Address); + Assert.Equal("Campinas", customer.Address.City); + } + } + finally + { + PreTest(typeof(PolicyCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldApplyInheritedNestedMapping() + { + PreTest(typeof(BaseCustomer), typeof(DerivedCustomer)); + + try + { + FluentMapper.Initialize(c => + { + c.AddMap(new BaseCustomerMap()); + c.AddMap(new DerivedCustomerMap()); + }); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 'Recife' AS city, 'vip' AS tier;"); + + Assert.NotNull(customer.Address); + Assert.Equal("Recife", customer.Address.City); + Assert.Equal("vip", customer.Tier); + } + } + finally + { + PreTest(typeof(BaseCustomer), typeof(DerivedCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldKeepNestedObjectNullWhenAllNestedColumnsAreNull() + { + PreTest(typeof(Customer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 7 AS customer_id, NULL AS city;"); + + Assert.Equal(7, customer.Id); + Assert.Null(customer.Address); + } + } + finally + { + PreTest(typeof(Customer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldCreateNestedObjectWhenSomeNestedColumnsAreNotNull() + { + PreTest(typeof(CustomerWithPostalCode)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerWithPostalCodeMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT NULL AS city, '01000' AS postal_code;"); + + Assert.NotNull(customer.Address); + Assert.Null(customer.Address.City); + Assert.Equal("01000", customer.Address.PostalCode); + } + } + finally + { + PreTest(typeof(CustomerWithPostalCode)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldUseExistingIntermediateObjectWhenAvailable() + { + PreTest(typeof(CustomerWithExistingAddress)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerWithExistingAddressMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 'Niteroi' AS city;"); + + Assert.NotNull(customer.Address); + Assert.Equal("created by constructor", customer.Address.CreatedBy); + Assert.Equal("Niteroi", customer.Address.City); + } + } + finally + { + PreTest(typeof(CustomerWithExistingAddress)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldClearExistingIntermediateObjectWhenAllNestedColumnsAreNull() + { + PreTest(typeof(CustomerWithExistingAddress)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerWithExistingAddressMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT NULL AS city;"); + + Assert.Null(customer.Address); + } + } + finally + { + PreTest(typeof(CustomerWithExistingAddress)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldMaterializeMultipleRows() + { + PreTest(typeof(Customer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerMap())); + + using (var connection = OpenConnection()) + { + var customers = connection.QueryMapped( + "SELECT 1 AS customer_id, 'Santos' AS city UNION ALL SELECT 2, 'Osasco';") + .ToList(); + + Assert.Collection( + customers, + first => + { + Assert.Equal(1, first.Id); + Assert.Equal("Santos", first.Address.City); + }, + second => + { + Assert.Equal(2, second.Id); + Assert.Equal("Osasco", second.Address.City); + }); + } + } + finally + { + PreTest(typeof(Customer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldContinueMaterializingTraditionalPocoFallback() + { + PreTest(typeof(TraditionalCustomer)); + + try + { + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 9 AS Id, 'Curitiba' AS Name;"); + + Assert.Equal(9, customer.Id); + Assert.Equal("Curitiba", customer.Name); + } + } + finally + { + PreTest(typeof(TraditionalCustomer)); + } + } + + [Fact] + public void InitializeShouldRejectUnsupportedCollectionInNestedPath() + { + PreTest(typeof(CollectionPathCustomer)); + + try + { + var exception = Assert.Throws( + () => FluentMapper.Initialize(c => c.AddMap(new CollectionPathCustomerMap()))); + + Assert.Contains("collection", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Items.Value", exception.Message, StringComparison.Ordinal); + } + finally + { + PreTest(typeof(CollectionPathCustomer)); + } + } + + [Fact] + public void QueryMappedShouldRejectNestedTypeWithoutPublicParameterlessConstructor() + { + PreTest(typeof(NonConstructibleCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new NonConstructibleCustomerMap())); + + using (var connection = OpenConnection()) + { + var exception = Assert.Throws( + () => connection.QueryMappedSingle("SELECT 'Sao Paulo' AS city;")); + + Assert.Contains("public parameterless constructor", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Address", exception.Message, StringComparison.Ordinal); + } + } + finally + { + PreTest(typeof(NonConstructibleCustomer)); + } + } + + [Fact] + public void InitializeShouldRejectReadonlyNestedPath() + { + PreTest(typeof(ReadOnlyPathCustomer)); + + try + { + var exception = Assert.Throws( + () => FluentMapper.Initialize(c => c.AddMap(new ReadOnlyPathCustomerMap()))); + + Assert.Contains("settable", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Address.City", exception.Message, StringComparison.Ordinal); + } + finally + { + PreTest(typeof(ReadOnlyPathCustomer)); + } + } + + [Fact] + public void InitializeShouldRejectConflictingNestedPathPrefix() + { + PreTest(typeof(ConflictingPathCustomer)); + + try + { + var exception = Assert.Throws( + () => FluentMapper.Initialize(c => c.AddMap(new ConflictingPathCustomerMap()))); + + Assert.Contains("conflicts", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Address", exception.Message, StringComparison.Ordinal); + Assert.Contains("Address.City", exception.Message, StringComparison.Ordinal); + } + finally + { + PreTest(typeof(ConflictingPathCustomer)); + } + } + + [Fact] + public void ExplainShouldDescribeNestedMaterialization() + { + PreTest(typeof(Customer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerMap())); + + var explanation = FluentMapper.Explain(); + var city = explanation.Members.Single(m => m.MemberPath == "Address.City"); + + Assert.Equal("city", city.ColumnName); + Assert.Equal(MappingSource.Explicit, city.Source); + Assert.Equal(MappingMaterialization.Nested, city.Materialization); + } + finally + { + PreTest(typeof(Customer)); + } + } + + private static SqliteConnection OpenConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + + private static void PreTest(params Type[] types) + { + FluentMapper.Reset(types); + } + + private sealed class Customer + { + public int Id { get; set; } + + public Address Address { get; set; } + } + + private sealed class CustomerMap : EntityMap + { + public CustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Address.City).ToColumn("city"); + } + } + + private sealed class Address + { + public string City { get; set; } + } + + private sealed class CustomerWithCountry + { + public AddressWithCountry Address { get; set; } + } + + private sealed class CustomerWithCountryMap : EntityMap + { + public CustomerWithCountryMap() + { + Map(customer => customer.Address.Country.Name).ToColumn("country_name"); + } + } + + private sealed class AddressWithCountry + { + public Country Country { get; set; } + } + + private sealed class Country + { + public string Name { get; set; } + } + + private sealed class SameTerminalCustomer + { + public RankInfo Rank { get; set; } + + public SeniorityInfo Seniority { get; set; } + } + + private sealed class RankInfo + { + public int Level { get; set; } + } + + private sealed class SeniorityInfo + { + public int Level { get; set; } + } + + private sealed class SameTerminalCustomerMap : EntityMap + { + public SameTerminalCustomerMap() + { + Map(customer => customer.Rank.Level).ToColumn("rank_level"); + Map(customer => customer.Seniority.Level).ToColumn("seniority_level"); + } + } + + private sealed class PolicyCustomer + { + public int CustomerId { get; set; } + + public PolicyAddress Address { get; set; } + } + + private sealed class PolicyAddress + { + public string City { get; set; } + } + + private sealed class PolicyCustomerMap : EntityMap + { + public PolicyCustomerMap() + { + Map(customer => customer.Address.City).ToColumn("city"); + } + } + + private class BaseCustomer + { + public Address Address { get; set; } + } + + private sealed class DerivedCustomer : BaseCustomer + { + public string Tier { get; set; } + } + + private sealed class BaseCustomerMap : EntityMap + { + public BaseCustomerMap() + { + Map(customer => customer.Address.City).ToColumn("city"); + } + } + + private sealed class DerivedCustomerMap : EntityMap + { + public DerivedCustomerMap() + { + IncludeBase(); + Map(customer => customer.Tier).ToColumn("tier"); + } + } + + private sealed class CustomerWithPostalCode + { + public PostalAddress Address { get; set; } + } + + private sealed class PostalAddress + { + public string City { get; set; } + + public string PostalCode { get; set; } + } + + private sealed class CustomerWithPostalCodeMap : EntityMap + { + public CustomerWithPostalCodeMap() + { + Map(customer => customer.Address.City).ToColumn("city"); + Map(customer => customer.Address.PostalCode).ToColumn("postal_code"); + } + } + + private sealed class CustomerWithExistingAddress + { + public CustomerWithExistingAddress() + { + Address = new ExistingAddress { CreatedBy = "created by constructor" }; + } + + public ExistingAddress Address { get; set; } + } + + private sealed class ExistingAddress + { + public string CreatedBy { get; set; } + + public string City { get; set; } + } + + private sealed class CustomerWithExistingAddressMap : EntityMap + { + public CustomerWithExistingAddressMap() + { + Map(customer => customer.Address.City).ToColumn("city"); + } + } + + private sealed class TraditionalCustomer + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private sealed class CollectionPathCustomer + { + public CollectionItem Items { get; set; } + } + + private sealed class CollectionItem : List + { + public string Value { get; set; } + } + + private sealed class CollectionLeaf + { + public string Value { get; set; } + } + + private sealed class CollectionPathCustomerMap : EntityMap + { + public CollectionPathCustomerMap() + { + Map(customer => customer.Items.Value).ToColumn("value"); + } + } + + private sealed class NonConstructibleCustomer + { + public NonConstructibleAddress Address { get; set; } + } + + private sealed class NonConstructibleAddress + { + public NonConstructibleAddress(string seed) + { + City = seed; + } + + public string City { get; set; } + } + + private sealed class NonConstructibleCustomerMap : EntityMap + { + public NonConstructibleCustomerMap() + { + Map(customer => customer.Address.City).ToColumn("city"); + } + } + + private sealed class ReadOnlyPathCustomer + { + public ReadOnlyAddress Address { get; set; } + } + + private sealed class ReadOnlyAddress + { + public string City { get; } + } + + private sealed class ReadOnlyPathCustomerMap : EntityMap + { + public ReadOnlyPathCustomerMap() + { + Map(customer => customer.Address.City).ToColumn("city"); + } + } + + private sealed class ConflictingPathCustomer + { + public Address Address { get; set; } + } + + private sealed class ConflictingPathCustomerMap : EntityMap + { + public ConflictingPathCustomerMap() + { + Map(customer => customer.Address).ToColumn("address"); + Map(customer => customer.Address.City).ToColumn("city"); + } + } + } +} From 68c9959d2c5483a9d92166747ad02e7490f589e6 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sun, 26 Jul 2026 14:22:01 -0300 Subject: [PATCH 13/20] feat: support immutable value object mappings --- README.md | 20 +- docs/sdd/etapa-5/03-value-objects.md | 328 +++++++ docs/sdd/etapa-5/decisions.md | 8 + docs/sdd/etapa-5/status.md | 2 +- .../Diagnostics/MappingMaterialization.cs | 7 +- .../MappingConfigurationValidator.cs | 12 +- src/Dapper.FluentMap/MappingRegistry.cs | 37 +- .../NestedMaterializationPlan.cs | 652 +++++++++++--- src/Dapper.FluentMap/QueryMappedExtensions.cs | 2 +- test/Dapper.FluentMap.AotSmoke/Program.cs | 44 + .../NestedMaterializationSpikeTests.cs | 13 +- .../NestedObjectMaterializationTests.cs | 18 +- .../ValueObjectMaterializationTests.cs | 808 ++++++++++++++++++ 13 files changed, 1815 insertions(+), 136 deletions(-) create mode 100644 docs/sdd/etapa-5/03-value-objects.md create mode 100644 test/Dapper.FluentMap.Tests/ValueObjectMaterializationTests.cs diff --git a/README.md b/README.md index 95781d3..2a6169d 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ public class ProductMap : EntityMap Column names are mapped case sensitive by default. You can change this by specifying the `caseSensitive` parameter in the `ToColumn()` method: `Map(p => p.Name).ToColumn("strName", caseSensitive: false)`. #### Nested object materialization -Nested paths can be configured with the same `Map(...)` API, but materializing the object graph is opt-in. Use `QueryMapped()` or `QueryMappedSingle()` when you want FluentMap to create supported mutable intermediate objects: +Nested paths can be configured with the same `Map(...)` API, but materializing the object graph is opt-in. Use `QueryMapped()` or `QueryMappedSingle()` when you want FluentMap to create supported intermediate objects or constructor-based immutable value objects: ```csharp public class CustomerMap : EntityMap @@ -62,7 +62,23 @@ var customer = connection.QueryMappedSingle( "SELECT 'Sao Paulo' AS city"); ``` -The regular `Dapper.Query()` path continues to handle root properties, conventions, constructor mapping and Dapper fallback as before. The nested materializer supports mutable intermediate objects with public parameterless constructors and settable properties; immutable nested value objects and nested records are reserved for a generated or constructor-based materializer. +Constructor-based Value Objects are supported when each mapped component can be bound to a public constructor parameter: + +```csharp +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(c => c.Id).ToColumn("id"); + Map(c => c.Cpf.Number).ToColumn("cpf"); + } +} + +var customer = connection.QueryMappedSingle( + "SELECT 1 AS id, '12345678909' AS cpf"); +``` + +The regular `Dapper.Query()` path continues to handle root properties, conventions, constructor mapping, TypeHandlers and Dapper fallback as before. For scalar Value Objects mapped as a whole, such as `Map(c => c.Cpf).ToColumn("cpf")`, prefer a Dapper `TypeHandler`. For nested paths such as `Map(c => c.Cpf.Number)`, `QueryMapped*` constructs the Value Object through public constructors and preserves domain invariants. Factory methods and generated materializers are not part of this runtime path. **Initialization:** ```csharp diff --git a/docs/sdd/etapa-5/03-value-objects.md b/docs/sdd/etapa-5/03-value-objects.md new file mode 100644 index 0000000..b88fe8d --- /dev/null +++ b/docs/sdd/etapa-5/03-value-objects.md @@ -0,0 +1,328 @@ +# 03 - Value Objects Imutaveis + +## Specification + +Esta entrega adiciona suporte opt-in para materializar Value Objects imutaveis e nested immutable objects pelo caminho `QueryMapped` / `QueryMappedSingle`, sem exigir setters publicos e sem contornar invariantes do dominio. + +Exemplo suportado: + +```csharp +Map(customer => customer.Id).ToColumn("id"); +Map(customer => customer.Cpf.Number).ToColumn("cpf"); + +var customer = connection.QueryMappedSingle( + "SELECT 1 AS id, '12345678909' AS cpf;"); +``` + +O modelo pode expor apenas getters e construtores publicos: + +```csharp +public sealed class Customer +{ + public Customer(int id, Cpf cpf) + { + Id = id; + Cpf = cpf; + } + + public int Id { get; } + public Cpf Cpf { get; } +} + +public sealed class Cpf +{ + public Cpf(string number) + { + Number = number; + } + + public string Number { get; } +} +``` + +## Discovery + +Arquivos analisados: + +- `docs/sdd/etapa-5/README.md` +- `docs/sdd/etapa-5/status.md` +- `docs/sdd/etapa-5/decisions.md` +- `docs/sdd/etapa-5/01-nested-materialization-spike.md` +- `docs/sdd/etapa-5/02-nested-object-materialization.md` +- `docs/sdd/etapa-3/02-constructor-immutable-mapping.md` +- `docs/sdd/etapa-4/02-trimming-aot.md` +- `docs/sdd/etapa-4/03-source-generator.md` +- `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs` +- `src/Dapper.FluentMap/MappingConfigurationValidator.cs` +- `src/Dapper.FluentMap/MappingRegistry.cs` +- `src/Dapper.FluentMap/Diagnostics/*` +- `test/Dapper.FluentMap.Tests/NestedObjectMaterializationTests.cs` +- `test/Dapper.FluentMap.Tests/NestedMaterializationSpikeTests.cs` +- `test/Dapper.FluentMap.AotSmoke/Program.cs` + +Confirmacao obrigatoria: + +```text +01 - Spike nested/value-object -> Concluido +02 - Nested object materialization -> Concluido +``` + +## Decision + +### TypeHandler Boundary + +Value Objects escalares mapeados como propriedade inteira continuam sendo responsabilidade do Dapper TypeHandler: + +```csharp +Map(customer => customer.Cpf).ToColumn("cpf"); +SqlMapper.AddTypeHandler(new CpfTypeHandler()); +``` + +Esse caminho permanece ideal para conversoes simples como `string -> Cpf`, inclusive com `Dapper.Query`. + +Para paths aninhados, o TypeHandler nao e suficiente: + +```csharp +Map(customer => customer.Cpf.Number).ToColumn("cpf"); +``` + +Nesse caso o destino conceitual e o grafo `Customer.Cpf`, nao apenas o membro terminal `Number`. O suporte foi implementado no materializer opt-in do FluentMap. + +### Constructor Strategy + +O plano runtime agora constroi uma arvore por `MemberPath` e seleciona construtores publicos por nome de parametro: + +- parametros sao associados a propriedades mapeadas por nome, case-insensitive; +- `Cpf(string number)` recebe `Cpf.Number`; +- `Money(decimal amount, string currency)` recebe `Money.Amount` e `Money.Currency`; +- `Customer(int id, Cpf cpf)` recebe o valor simples `Id` e o objeto `Cpf` ja materializado; +- objetos mutaveis com construtor publico sem parametros e setters continuam usando o caminho anterior; +- construtores sao pre-computados no plano por tipo + shape de colunas; +- o hot path por linha usa delegates compilados e bindings ja resolvidos. + +Factory methods como `Cpf.Create(...)` foram avaliados, mas nao implementados. Uma DSL de factories exigiria uma API publica explicita, forte em tipos e com regras de ambiguidade proprias. Esta entrega manteve somente construtores publicos. + +## Supported Scope + +Suportado nesta entrega: + +- Value Object de um valor, como `Cpf(string number)`; +- record de um valor, como `record Email(string Value)`; +- Value Object com varios componentes, como `Money(decimal amount, string currency)`; +- nested immutable object, como `Customer(Address address)` e `Address(string city)`; +- Value Object nullable por semantica runtime de referencia: subarvore toda `NULL` resulta em `null`; +- dois Value Objects no mesmo tipo; +- paths com mesmo terminal em objetos distintos; +- mappings herdados por `IncludeBase()`; +- naming policy para propriedades raiz combinada com nested value object explicito; +- root immutable constructor mapping no caminho `QueryMapped*`; +- TypeHandler quando o destino mapeado e o Value Object inteiro. + +Fora do escopo: + +- factory methods; +- private constructor; +- private setter via reflection; +- field/backing field injection; +- `FormatterServices`; +- colecoes no meio do path; +- nullability NRT como contrato runtime; +- generated DbDataReader materializer. + +## Validation + +Configuracoes impossiveis sao rejeitadas com `FluentMapConfigurationException`: + +- path com indexer, static member, sem getter publico ou colecao intermediaria; +- propriedade sem setter que nao possa ser associada a parametro de construtor publico; +- tipo sem construtor publico compativel com os membros mapeados; +- parametro de construtor sem coluna/membro correspondente; +- multiplos construtores publicos igualmente validos; +- prefix conflict como `Address` e `Address.City`; +- Value Object que nao pode ser criado pelo conjunto de colunas consultado. + +As mensagens incluem o tipo de entidade, `MemberPath`, tipo do Value Object, construtor quando aplicavel e colunas problemáticas. + +## Null Semantics + +A regra de `NULL` continua por subarvore: + +- se todos os valores de uma subarvore Value Object sao `NULL`, o Value Object fica `null`; +- se pelo menos um valor da subarvore nao e `NULL`, o Value Object e construido; +- valores `NULL` para parametros escalares reference/nullable chegam como `null`; +- valores `NULL` para value types nao anulaveis seguem o comportamento ja existente do materializer: default do tipo; +- NRT (`Cpf?`) nao e interpretado como metadata runtime nesta entrega. + +## Exceptions + +Construtores de dominio continuam sendo a autoridade para invariantes. + +Quando um construtor rejeita um valor, a excecao original e preservada como `InnerException` de `FluentMapConfigurationException`, com contexto adicional: + +- entidade; +- `MemberPath`; +- tipo do Value Object; +- construtor usado; +- coluna ou colunas envolvidas. + +Nao ha silencio de excecao nem criacao sem construtor. + +## Diagnostics + +`MappingMaterialization` recebeu: + +```csharp +ValueObject +``` + +`Explain()` agora distingue: + +- `Dapper` para root mapping regular; +- `Nested` para nested mutable materialization; +- `ValueObject` para paths aninhados que exigem construtor. + +Exemplo validado: + +```text +Cpf.Number + Column: cpf + Source: Explicit + Materialization: ValueObject +``` + +## Analyzer And Generator + +Analyzer: + +- nenhuma regra nova foi adicionada, porque construtores/factories e cobertura de colunas dependem do shape runtime da query; +- os testes existentes de analyzer foram preservados para garantir que nested/value-object member expressions continuam validas; +- regras estaticamente comprovaveis existentes (`DFM001` a `DFM005`) permanecem. + +Source generator: + +- nao foi transformado em materializer; +- o generator continua limitado a registro de maps; +- o smoke AOT recebeu um mapping de Value Object e valida `Explain` com `Materialization = ValueObject`; +- materializer gerado permanece estrategia futura para consumidores Native AOT/trimming que nao queiram usar `QueryMapped*` reflection-based. + +## AOT And Performance + +`QueryMapped*` continua anotado com: + +- `RequiresUnreferencedCode`; +- `RequiresDynamicCode`. + +Motivo: o caminho runtime usa reflection metadata e expression compilation para getters, setters, construtores e TypeHandler binding. + +Mitigacoes implementadas: + +- plano cacheado por tipo raiz + lista ordinal de colunas; +- selecao de construtor feita uma vez por plano; +- delegates de construtor/getter/setter/conversor pre-computados; +- sem lookup de construtor por row; +- sem expression tree criada por row. + +## Delivery + +Arquivos adicionados: + +- `test/Dapper.FluentMap.Tests/ValueObjectMaterializationTests.cs` +- `docs/sdd/etapa-5/03-value-objects.md` + +Arquivos alterados: + +- `README.md` +- `src/Dapper.FluentMap/Diagnostics/MappingMaterialization.cs` +- `src/Dapper.FluentMap/MappingConfigurationValidator.cs` +- `src/Dapper.FluentMap/MappingRegistry.cs` +- `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs` +- `src/Dapper.FluentMap/QueryMappedExtensions.cs` +- `test/Dapper.FluentMap.Tests/NestedMaterializationSpikeTests.cs` +- `test/Dapper.FluentMap.Tests/NestedObjectMaterializationTests.cs` +- `test/Dapper.FluentMap.AotSmoke/Program.cs` +- `docs/sdd/etapa-5/decisions.md` +- `docs/sdd/etapa-5/status.md` + +Nao foram alterados: + +- Dommel; +- targets; +- metadados NuGet; +- pacote do analyzer; +- source generator runtime. + +## Validation + +Validacao localizada executada durante a implementacao: + +```text +dotnet build .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Debug +dotnet build .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Debug +dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~ValueObjectMaterializationTests" +dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~ValueObjectMaterializationTests|FullyQualifiedName~NestedObjectMaterializationTests|FullyQualifiedName~NestedMaterializationSpikeTests|FullyQualifiedName~ConstructorMappingTests|FullyQualifiedName~DiagnosticsApiTests" +``` + +Resultados locais: + +- build do core: sucesso, 0 warnings, 0 erros; +- build dos testes do core: sucesso, 0 warnings, 0 erros; +- `ValueObjectMaterializationTests`: sucesso, 16 testes aprovados; +- conjunto relacionado: sucesso, 60 testes aprovados. + +Validacao final completa registrada ao concluir a entrega: + +```text +dotnet restore +dotnet build +dotnet test +dotnet build --configuration Release +dotnet test --configuration Release +dotnet pack .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release --no-build --output .\artifacts\packages +``` + +Resultado final: + +- `dotnet restore`: sucesso; +- `dotnet build`: sucesso, 0 warnings, 0 erros; +- `dotnet test`: sucesso, 166 testes do core, 7 Dommel, 7 analyzer, 12 generator e 1 generated-registration integration; +- `dotnet build --configuration Release`: sucesso, 0 warnings, 0 erros; +- `dotnet test --configuration Release`: sucesso com os mesmos 193 testes totais; +- `dotnet pack`: pacote `Dapper.FluentMap.2.0.0.nupkg` criado; warning legado `NU5125` sobre `PackageLicenseUrl`. + +Inspecao do pacote: + +- contem `lib/netstandard2.0/Dapper.FluentMap.dll`; +- contem `lib/netstandard2.0/Dapper.FluentMap.xml`; +- nao contem projetos de teste nem artefatos indevidos. + +Smokes especificos registrados ao concluir a entrega: + +```text +dotnet test .\test\Dapper.FluentMap.Analyzers.Tests\Dapper.FluentMap.Analyzers.Tests.csproj +dotnet test .\test\Dapper.FluentMap.Generators.Tests\Dapper.FluentMap.Generators.Tests.csproj +dotnet test .\test\Dapper.FluentMap.GeneratedRegistration.Tests\Dapper.FluentMap.GeneratedRegistration.Tests.csproj +dotnet run --project .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release +dotnet run --project .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:DefineConstants=AOT_SMOKE_GENERATED +dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishTrimmed=true -p:TrimMode=full -p:PublishAot=false -p:TrimmerSingleWarn=false -p:ILLinkTreatWarningsAsErrors=false +dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishTrimmed=true -p:TrimMode=full -p:PublishAot=false -p:DefineConstants=AOT_SMOKE_GENERATED -p:TrimmerSingleWarn=false -p:ILLinkTreatWarningsAsErrors=false +dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishAot=true -p:TrimmerSingleWarn=false -p:ILLinkTreatWarningsAsErrors=false -p:MSBuildWarningsAsMessages= +``` + +Resultados: + +- testes de analyzer: sucesso, 7 testes aprovados; +- testes de generator: sucesso, 12 testes aprovados; +- generated-registration integration: sucesso, 1 teste aprovado; +- AOT smoke explicit: `explicit:ok`; +- AOT smoke generated: `generated:ok`; +- publish trimmed explicit: sucesso, runtime `explicit:ok`, sem warnings FluentMap-owned; warnings restantes pertencem ao Dapper; +- publish trimmed generated: sucesso, runtime `generated:ok`, sem warnings FluentMap-owned; warnings restantes pertencem ao Dapper; +- publish Native AOT explicit: falhou no ambiente com `Platform linker not found`; runtime Native AOT nao foi validado. + +## Semantic Commit + +Mensagem: + +```text +feat: support immutable value object mappings +``` diff --git a/docs/sdd/etapa-5/decisions.md b/docs/sdd/etapa-5/decisions.md index 204a439..bb025c1 100644 --- a/docs/sdd/etapa-5/decisions.md +++ b/docs/sdd/etapa-5/decisions.md @@ -19,14 +19,22 @@ Registre aqui apenas decisoes arquiteturais necessarias as proximas entregas. - Value Objects escalares devem usar o mecanismo publico de TypeHandlers do Dapper quando o mapping aponta para a propriedade Value Object inteira, por exemplo `Map(x => x.Cpf).ToColumn("cpf")`. - TypeHandler nao resolve nested path arbitrario como `Map(x => x.Cpf.Number).ToColumn("cpf")`, porque o Dapper passa a converter e atribuir o membro terminal (`Number`), nao o Value Object (`Cpf`). - Value Objects imutaveis dentro de grafos aninhados exigem materializacao controlada pelo FluentMap ou geracao de materializer; nao devem ser declarados suportados por `ITypeMap` puro. +- `QueryMapped*` suporta nested Value Objects por construtores publicos quando todos os parametros exigidos correspondem a propriedades mapeadas ou objetos aninhados mapeados. +- Factory methods como `Cpf.Create(...)` nao foram implementados nesta etapa; qualquer suporte futuro deve ser API publica explicita, fortemente tipada e com regras de ambiguidade proprias. +- Nao ha suporte a private constructor, private setter, field injection, `FormatterServices` ou alteracao de backing field. +- A semantica de `NULL` para Value Object e por subarvore: se todas as colunas da subarvore sao `NULL`, o Value Object resultante e `null`; se alguma coluna possui valor, o construtor publico e usado. +- Excecoes de dominio lancadas por construtores sao preservadas como `InnerException` de `FluentMapConfigurationException` com contexto de entidade, `MemberPath`, tipo, construtor e colunas. ## Records E Imutabilidade - Records posicionais e classes imutaveis simples continuam sendo responsabilidade do constructor mapping existente quando todos os parametros sao simples. - Nested records, nested immutable objects e construcao de Value Objects por construtor devem ser tratados por uma estrategia complementar ao `ITypeMap` do Dapper. +- Nested records e nested immutable objects passam a ser suportados no caminho opt-in `QueryMapped*` quando a arvore completa pode ser construida por construtores publicos compativeis. +- Mappings simples de records/classes imutaveis via `Dapper.Query` continuam preservados pelo constructor mapping da Etapa 3. ## Source Generation, Trimming E AOT - O generator da Etapa 4 continua limitado a registro de mappings. - Um materializer gerado pode ser uma estrategia futura para performance, trimming e Native AOT, mas nao deve ser acoplado a Entrega 2 como unico caminho. - O caminho runtime/reflection-based de `QueryMapped*` e documentado como menos AOT-friendly e foi anotado com `RequiresUnreferencedCode` e `RequiresDynamicCode`; o caminho gerado deve ser a opcao preferencial para consumidores trimmed/AOT quando existir. +- A Entrega 3 nao amplia o generator para materializar `DbDataReader`; o smoke AOT valida registro/diagnostico de Value Object, nao runtime AOT completo de `QueryMapped*`. diff --git a/docs/sdd/etapa-5/status.md b/docs/sdd/etapa-5/status.md index 72fa7d7..86d6c64 100644 --- a/docs/sdd/etapa-5/status.md +++ b/docs/sdd/etapa-5/status.md @@ -2,5 +2,5 @@ |---|---|---| | 01 - Spike nested/value-object | Concluido | - | | 02 - Nested object materialization | Concluido | - | -| 03 - Value Objects imutaveis | Pendente | - | +| 03 - Value Objects imutaveis | Concluido | - | | 04 - Mapping profiles | Pendente | - | diff --git a/src/Dapper.FluentMap/Diagnostics/MappingMaterialization.cs b/src/Dapper.FluentMap/Diagnostics/MappingMaterialization.cs index e0914ea..fb7b590 100644 --- a/src/Dapper.FluentMap/Diagnostics/MappingMaterialization.cs +++ b/src/Dapper.FluentMap/Diagnostics/MappingMaterialization.cs @@ -13,6 +13,11 @@ public enum MappingMaterialization /// /// The member is materialized by FluentMap's opt-in nested object materializer. /// - Nested + Nested, + + /// + /// The member is materialized by FluentMap's opt-in constructor-based value object materializer. + /// + ValueObject } } diff --git a/src/Dapper.FluentMap/MappingConfigurationValidator.cs b/src/Dapper.FluentMap/MappingConfigurationValidator.cs index 480c1de..49d5868 100644 --- a/src/Dapper.FluentMap/MappingConfigurationValidator.cs +++ b/src/Dapper.FluentMap/MappingConfigurationValidator.cs @@ -266,11 +266,6 @@ private static void ValidateNestedMaterializationPath(Type entityType, MemberPat throw UnsupportedNestedPath(entityType, memberPath, sourceKind, sourceType, $"Property '{property.Name}' must have a public getter."); } - if (!CanWrite(property)) - { - throw UnsupportedNestedPath(entityType, memberPath, sourceKind, sourceType, $"Property '{property.Name}' must be settable."); - } - if (i == properties.Count - 1) { continue; @@ -281,6 +276,7 @@ private static void ValidateNestedMaterializationPath(Type entityType, MemberPat { throw UnsupportedNestedPath(entityType, memberPath, sourceKind, sourceType, $"Intermediate property '{property.Name}' has unsupported type '{FormatType(propertyType)}'. Collections and scalar values cannot appear in the middle of a nested path."); } + } } @@ -308,12 +304,6 @@ private static bool CanRead(PropertyInfo property) return getter != null && !getter.IsStatic; } - private static bool CanWrite(PropertyInfo property) - { - var setter = property.GetSetMethod(); - return setter != null && !setter.IsStatic; - } - private static bool IsStatic(PropertyInfo property) { var getter = property.GetGetMethod(); diff --git a/src/Dapper.FluentMap/MappingRegistry.cs b/src/Dapper.FluentMap/MappingRegistry.cs index 12e87d3..9cc4997 100644 --- a/src/Dapper.FluentMap/MappingRegistry.cs +++ b/src/Dapper.FluentMap/MappingRegistry.cs @@ -467,9 +467,7 @@ private void AddMemberExplanation( var constructorParameters = descriptor.Map.Ignored || memberPath.IsNested ? new ConstructorParameterExplanation[0] : GetConstructorParameters(entityType, descriptor.Map.PropertyInfo); - var materialization = memberPath.IsNested - ? MappingMaterialization.Nested - : MappingMaterialization.Dapper; + var materialization = GetMaterialization(memberPath); members.Add(new MemberMappingExplanation( memberPath.ToString(), @@ -485,6 +483,39 @@ private void AddMemberExplanation( configuredPaths.Add(memberPath); } + private static MappingMaterialization GetMaterialization(MemberPath memberPath) + { + if (!memberPath.IsNested) + { + return MappingMaterialization.Dapper; + } + + return RequiresConstructorMaterialization(memberPath) + ? MappingMaterialization.ValueObject + : MappingMaterialization.Nested; + } + + private static bool RequiresConstructorMaterialization(MemberPath memberPath) + { + var properties = memberPath.Properties; + for (var i = 0; i < properties.Count; i++) + { + if (!CanWrite(properties[i])) + { + return true; + } + + } + + return false; + } + + private static bool CanWrite(PropertyInfo property) + { + var setter = property.GetSetMethod(); + return setter != null && !setter.IsStatic; + } + private static IEnumerable GetConstructorParameters( [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type entityType, diff --git a/src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs b/src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs index ad94108..ea2eda5 100644 --- a/src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs +++ b/src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs @@ -11,18 +11,11 @@ namespace Dapper.FluentMap.Materialization { internal sealed class NestedMaterializationPlan { - private readonly Func _entityFactory; - private readonly Assignment[] _rootAssignments; - private readonly NestedNode[] _nestedNodes; - - private NestedMaterializationPlan( - Func entityFactory, - IEnumerable rootAssignments, - IEnumerable nestedNodes) + private readonly MaterializationNode _rootNode; + + private NestedMaterializationPlan(MaterializationNode rootNode) { - _entityFactory = entityFactory; - _rootAssignments = rootAssignments.ToArray(); - _nestedNodes = nestedNodes.ToArray(); + _rootNode = rootNode; } internal static NestedMaterializationPlan Create(Type entityType, IReadOnlyList columnNames, MappingRegistry registry) @@ -42,10 +35,8 @@ internal static NestedMaterializationPlan Create(Type entityType, IReadOnlyList< throw new ArgumentNullException(nameof(registry)); } - var entityFactory = CreateFactory(entityType, $"Entity type '{FormatType(entityType)}' must have a public parameterless constructor to use QueryMapped when nested mappings are present."); var defaultTypeMap = new DefaultTypeMap(entityType); - var rootAssignments = new List(); - var nestedNodes = new List(); + var rootNode = MaterializationNode.Root(entityType); for (var i = 0; i < columnNames.Count; i++) { @@ -59,13 +50,7 @@ internal static NestedMaterializationPlan Create(Type entityType, IReadOnlyList< } var memberPath = PropertyMapIdentity.GetMemberPath(fluentMap); - if (memberPath.IsNested) - { - AddNestedAssignment(nestedNodes, memberPath, i); - continue; - } - - rootAssignments.Add(Assignment.ForProperty(i, memberPath.PropertyInfo)); + rootNode.AddPropertyPath(memberPath, i, columnName); continue; } @@ -77,77 +62,50 @@ internal static NestedMaterializationPlan Create(Type entityType, IReadOnlyList< if (defaultMember.Property != null) { - rootAssignments.Add(Assignment.ForProperty(i, defaultMember.Property)); + rootNode.AddRootProperty(defaultMember.Property, i, columnName); } else if (defaultMember.Field != null) { - rootAssignments.Add(Assignment.ForField(i, defaultMember.Field)); + rootNode.AddRootField(defaultMember.Field, i, columnName); } } - foreach (var node in nestedNodes) - { - node.Seal(); - } + rootNode.Seal(entityType); - return new NestedMaterializationPlan(entityFactory, rootAssignments, nestedNodes); + return new NestedMaterializationPlan(rootNode); } internal object Materialize(IDataRecord record) { - var entity = _entityFactory(); - - foreach (var assignment in _rootAssignments) - { - assignment.Assign(entity, record); - } - - foreach (var node in _nestedNodes) - { - node.Apply(entity, record); - } - - return entity; + return _rootNode.MaterializeRoot(record); } - private static void AddNestedAssignment(IList rootNodes, MemberPath memberPath, int columnIndex) + private static Func CreateParameterlessFactory(Type type) { - var properties = memberPath.Properties; - var nodes = rootNodes; - var node = default(NestedNode); - - for (var i = 0; i < properties.Count - 1; i++) + var constructor = type.GetConstructor(Type.EmptyTypes); + if (constructor == null) { - node = FindOrAddNode(nodes, properties[i]); - nodes = node.Children; + return null; } - node.Leaves.Add(Assignment.ForProperty(columnIndex, properties[properties.Count - 1])); + var body = Expression.Convert(Expression.New(constructor), typeof(object)); + return Expression.Lambda>(body).Compile(); } - private static NestedNode FindOrAddNode(IList nodes, PropertyInfo property) + private static Func CreateConstructorFactory(ConstructorInfo constructor) { - var node = nodes.FirstOrDefault(n => Equals(n.Property, property)); - if (node != null) - { - return node; - } - - node = new NestedNode(property); - nodes.Add(node); - return node; - } + var args = Expression.Parameter(typeof(object[]), "args"); + var parameters = constructor.GetParameters(); + var arguments = new Expression[parameters.Length]; - private static Func CreateFactory(Type type, string errorMessage) - { - var constructor = type.GetConstructor(Type.EmptyTypes); - if (constructor == null) + for (var i = 0; i < parameters.Length; i++) { - throw new FluentMapConfigurationException(errorMessage); + var item = Expression.ArrayIndex(args, Expression.Constant(i)); + arguments[i] = Expression.Convert(item, parameters[i].ParameterType); } - var body = Expression.Convert(Expression.New(constructor), typeof(object)); - return Expression.Lambda>(body).Compile(); + var body = Expression.Convert(Expression.New(constructor, arguments), typeof(object)); + return Expression.Lambda>(body, args).Compile(); } private static Func CreateGetter(PropertyInfo property) @@ -162,6 +120,11 @@ private static Func CreateGetter(PropertyInfo property) private static Action CreatePropertySetter(PropertyInfo property) { + if (!CanWrite(property)) + { + return null; + } + var target = Expression.Parameter(typeof(object), "target"); var value = Expression.Parameter(typeof(object), "value"); var body = Expression.Assign( @@ -182,6 +145,45 @@ private static Action CreateFieldSetter(FieldInfo field) return Expression.Lambda>(body, target, value).Compile(); } + private static Func CreateConverter(Type targetType) + { + var conversionType = Nullable.GetUnderlyingType(targetType) ?? targetType; + if (SqlMapper.HasTypeHandler(conversionType)) + { + return CreateTypeHandlerConverter(conversionType); + } + + return value => ConvertValue(value, targetType); + } + + private static Func CreateTypeHandlerConverter(Type targetType) + { + var value = Expression.Parameter(typeof(object), "value"); + var cacheTypeDefinition = typeof(SqlMapper).GetNestedType("TypeHandlerCache`1", BindingFlags.Public | BindingFlags.NonPublic); + if (cacheTypeDefinition == null) + { + return raw => ConvertValue(raw, targetType); + } + + var cacheType = cacheTypeDefinition.MakeGenericType(targetType); + var parse = cacheType.GetMethod("Parse", BindingFlags.Public | BindingFlags.Static, null, new[] { typeof(object) }, null); + + if (parse == null) + { + return raw => ConvertValue(raw, targetType); + } + + var nullValue = Expression.Constant(GetDefaultValue(targetType), typeof(object)); + var body = Expression.Condition( + Expression.OrElse( + Expression.Equal(value, Expression.Constant(null, typeof(object))), + Expression.Equal(value, Expression.Constant(DBNull.Value, typeof(object)))), + nullValue, + Expression.Convert(Expression.Call(parse, value), typeof(object))); + + return Expression.Lambda>(body, value).Compile(); + } + private static object ConvertValue(object value, Type targetType) { if (value == null || value == DBNull.Value) @@ -225,63 +227,157 @@ private static bool CanAssignNull(Type type) return !type.GetTypeInfo().IsValueType || Nullable.GetUnderlyingType(type) != null; } + private static bool CanRead(PropertyInfo property) + { + var getter = property.GetGetMethod(); + return getter != null && !getter.IsStatic; + } + + private static bool CanWrite(PropertyInfo property) + { + var setter = property.GetSetMethod(); + return setter != null && !setter.IsStatic; + } + private static bool HasNonNullValue(IDataRecord record, IEnumerable columnIndexes) { return columnIndexes.Any(index => !record.IsDBNull(index)); } + private static bool IsParameterCompatible(Type parameterType, Type sourceType) + { + var parameter = Nullable.GetUnderlyingType(parameterType) ?? parameterType; + var source = Nullable.GetUnderlyingType(sourceType) ?? sourceType; + return parameter.GetTypeInfo().IsAssignableFrom(source.GetTypeInfo()) || + source.GetTypeInfo().IsAssignableFrom(parameter.GetTypeInfo()); + } + + private static int GetCompatibilityScore(Type parameterType, Type sourceType) + { + var parameter = Nullable.GetUnderlyingType(parameterType) ?? parameterType; + var source = Nullable.GetUnderlyingType(sourceType) ?? sourceType; + return parameter == source ? 2 : 1; + } + private static string FormatType(Type type) { return type == null ? "" : type.FullName; } - private sealed class NestedNode + private static string FormatConstructor(ConstructorInfo constructor) + { + var parameters = constructor.GetParameters() + .Select(parameter => FormatType(parameter.ParameterType) + " " + parameter.Name); + + return FormatType(constructor.DeclaringType) + "(" + string.Join(", ", parameters) + ")"; + } + + private sealed class MaterializationNode { + private readonly List _leaves = new List(); + private readonly List _children = new List(); + private readonly bool _isRoot; private int[] _subtreeColumnIndexes; + private Func _parameterlessFactory; + private ConstructorPlan _constructorPlan; + private NestedLeaf[] _postConstructorLeaves; + private MaterializationNode[] _postConstructorChildren; - internal NestedNode(PropertyInfo property) + private MaterializationNode(Type type, PropertyInfo parentProperty, string memberPath, bool isRoot) { - Property = property; - Children = new List(); - Leaves = new List(); - Getter = CreateGetter(property); - Setter = CreatePropertySetter(property); - Factory = CreateFactory( - property.PropertyType, - $"Intermediate property '{property.Name}' of type '{FormatType(property.PropertyType)}' must have a public parameterless constructor for nested materialization."); + Type = type; + ParentProperty = parentProperty; + MemberPath = memberPath; + _isRoot = isRoot; + + if (parentProperty != null) + { + Getter = CanRead(parentProperty) ? CreateGetter(parentProperty) : null; + Setter = CreatePropertySetter(parentProperty); + } } - internal PropertyInfo Property { get; } + internal Type Type { get; } - internal IList Children { get; } + internal PropertyInfo ParentProperty { get; } - internal IList Leaves { get; } + internal string MemberPath { get; } - private Func Getter { get; } + internal Func Getter { get; } - private Action Setter { get; } + internal Action Setter { get; } + + internal bool CanAssignToParent => _isRoot || Setter != null; + + internal static MaterializationNode Root(Type type) + { + return new MaterializationNode(type, null, type.Name, isRoot: true); + } - private Func Factory { get; } + internal void AddPropertyPath(MemberPath memberPath, int columnIndex, string columnName) + { + var properties = memberPath.Properties; + if (!memberPath.IsNested) + { + AddRootProperty(properties[0], columnIndex, columnName); + return; + } + + var node = this; + for (var i = 0; i < properties.Count - 1; i++) + { + node = node.FindOrAddChild(properties[i]); + } + + node._leaves.Add(NestedLeaf.ForProperty(properties[properties.Count - 1], columnIndex, columnName, memberPath.ToString())); + } + + internal void AddRootProperty(PropertyInfo property, int columnIndex, string columnName) + { + _leaves.Add(NestedLeaf.ForProperty(property, columnIndex, columnName, property.Name)); + } + + internal void AddRootField(FieldInfo field, int columnIndex, string columnName) + { + _leaves.Add(NestedLeaf.ForField(field, columnIndex, columnName, field.Name)); + } - internal void Seal() + internal void Seal(Type entityType) { - foreach (var child in Children) + foreach (var child in _children) { - child.Seal(); + child.Seal(entityType); } - _subtreeColumnIndexes = Leaves + _subtreeColumnIndexes = _leaves .Select(leaf => leaf.ColumnIndex) - .Concat(Children.SelectMany(child => child._subtreeColumnIndexes)) + .Concat(_children.SelectMany(child => child._subtreeColumnIndexes)) .Distinct() .ToArray(); + + SelectConstructionPlan(entityType); + } + + internal object MaterializeRoot(IDataRecord record) + { + return Materialize(record, existing: null, forceNew: true); + } + + internal object MaterializeValue(IDataRecord record) + { + if (!HasNonNullValue(record, _subtreeColumnIndexes)) + { + return null; + } + + return Materialize(record, existing: null, forceNew: true); } internal void Apply(object parent, IDataRecord record) { if (!HasNonNullValue(record, _subtreeColumnIndexes)) { - if (CanAssignNull(Property.PropertyType)) + if (Setter != null && CanAssignNull(ParentProperty.PropertyType)) { Setter(parent, null); } @@ -289,52 +385,396 @@ internal void Apply(object parent, IDataRecord record) return; } - var current = Getter(parent); - if (current == null) + var existing = _constructorPlan == null && Getter != null + ? Getter(parent) + : null; + var value = Materialize(record, existing, forceNew: false); + + if (Setter != null && (!ReferenceEquals(existing, value) || _constructorPlan != null)) { - current = Factory(); - Setter(parent, current); + Setter(parent, value); } + } - foreach (var child in Children) + private MaterializationNode FindOrAddChild(PropertyInfo property) + { + var child = _children.FirstOrDefault(node => Equals(node.ParentProperty, property)); + if (child != null) + { + return child; + } + + var memberPath = _isRoot + ? property.Name + : MemberPath + "." + property.Name; + child = new MaterializationNode(property.PropertyType, property, memberPath, isRoot: false); + _children.Add(child); + return child; + } + + private void SelectConstructionPlan(Type entityType) + { + _parameterlessFactory = CreateParameterlessFactory(Type); + + var requiresConstructor = _parameterlessFactory == null || + _leaves.Any(leaf => !leaf.CanAssign) || + _children.Any(child => !child.CanAssignToParent); + + if (!requiresConstructor) + { + _postConstructorLeaves = _leaves.ToArray(); + _postConstructorChildren = _children.ToArray(); + return; + } + + _constructorPlan = SelectConstructor(entityType); + if (_constructorPlan == null) + { + throw new FluentMapConfigurationException( + $"Type '{FormatType(Type)}' at member path '{MemberPath}' on entity '{FormatType(entityType)}' cannot be materialized. No public constructor matches the mapped properties or nested value objects. Columns: {FormatColumns()}."); + } + + _postConstructorLeaves = _leaves + .Where(leaf => !_constructorPlan.Uses(leaf)) + .ToArray(); + _postConstructorChildren = _children + .Where(child => !_constructorPlan.Uses(child)) + .ToArray(); + + var unsupportedLeaf = _postConstructorLeaves.FirstOrDefault(leaf => !leaf.CanAssign); + if (unsupportedLeaf != null) + { + throw new FluentMapConfigurationException( + $"Type '{FormatType(Type)}' at member path '{MemberPath}' on entity '{FormatType(entityType)}' cannot assign mapped property '{unsupportedLeaf.MemberPath}'. It has no public setter and is not bound to constructor '{FormatConstructor(_constructorPlan.Constructor)}'. Column: '{unsupportedLeaf.ColumnName}'."); + } + + var unsupportedChild = _postConstructorChildren.FirstOrDefault(child => !child.CanAssignToParent); + if (unsupportedChild != null) + { + throw new FluentMapConfigurationException( + $"Type '{FormatType(Type)}' at member path '{MemberPath}' on entity '{FormatType(entityType)}' cannot assign nested value object '{unsupportedChild.MemberPath}'. It has no public setter and is not bound to constructor '{FormatConstructor(_constructorPlan.Constructor)}'."); + } + } + + private ConstructorPlan SelectConstructor(Type entityType) + { + var candidates = Type.GetConstructors(BindingFlags.Public | BindingFlags.Instance) + .Select(constructor => TryCreateConstructorPlan(entityType, constructor)) + .Where(plan => plan != null) + .ToList(); + + if (candidates.Count == 0) + { + return null; + } + + var bestScore = candidates.Max(candidate => candidate.Score); + var best = candidates + .Where(candidate => candidate.Score == bestScore) + .ToList(); + + if (best.Count > 1) + { + throw new FluentMapConfigurationException( + $"Type '{FormatType(Type)}' at member path '{MemberPath}' on entity '{FormatType(entityType)}' has multiple public constructors that match the mapped columns: {string.Join("; ", best.Select(plan => FormatConstructor(plan.Constructor)))}."); + } + + return best[0]; + } + + private ConstructorPlan TryCreateConstructorPlan(Type entityType, ConstructorInfo constructor) + { + var bindings = new List(); + var score = 0; + + foreach (var parameter in constructor.GetParameters()) + { + var binding = TryBindParameter(parameter); + if (binding == null) + { + return null; + } + + bindings.Add(binding); + score += binding.Score; + } + + foreach (var leaf in _leaves.Where(leaf => !leaf.CanAssign)) + { + if (!bindings.Any(binding => binding.Leaf == leaf)) + { + return null; + } + } + + foreach (var child in _children.Where(child => !child.CanAssignToParent)) + { + if (!bindings.Any(binding => binding.Child == child)) + { + return null; + } + } + + return new ConstructorPlan( + entityType, + MemberPath, + constructor, + CreateConstructorFactory(constructor), + bindings, + score); + } + + private ParameterBinding TryBindParameter(ParameterInfo parameter) + { + var leafMatches = _leaves + .Where(leaf => leaf.Property != null && + string.Equals(leaf.Property.Name, parameter.Name, StringComparison.OrdinalIgnoreCase) && + IsParameterCompatible(parameter.ParameterType, leaf.TargetType)) + .Select(leaf => ParameterBinding.ForLeaf(parameter, leaf, GetCompatibilityScore(parameter.ParameterType, leaf.TargetType))); + + var childMatches = _children + .Where(child => string.Equals(child.ParentProperty.Name, parameter.Name, StringComparison.OrdinalIgnoreCase) && + IsParameterCompatible(parameter.ParameterType, child.ParentProperty.PropertyType)) + .Select(child => ParameterBinding.ForChild(parameter, child, GetCompatibilityScore(parameter.ParameterType, child.ParentProperty.PropertyType))); + + var matches = leafMatches + .Concat(childMatches) + .OrderByDescending(binding => binding.Score) + .ToList(); + + if (matches.Count == 0) + { + return null; + } + + var bestScore = matches[0].Score; + var best = matches.Where(match => match.Score == bestScore).ToList(); + return best.Count == 1 ? best[0] : null; + } + + private object Materialize(IDataRecord record, object existing, bool forceNew) + { + var current = _constructorPlan != null + ? _constructorPlan.Create(record) + : forceNew || existing == null + ? _parameterlessFactory() + : existing; + + foreach (var child in _postConstructorChildren) { child.Apply(current, record); } - foreach (var leaf in Leaves) + foreach (var leaf in _postConstructorLeaves) { leaf.Assign(current, record); } + + return current; + } + + private string FormatColumns() + { + return string.Join(", ", _leaves.Select(leaf => "'" + leaf.ColumnName + "'") + .Concat(_children.SelectMany(child => child.GetColumnNames().Select(column => "'" + column + "'")))); + } + + internal IEnumerable GetColumnNames() + { + return _leaves.Select(leaf => leaf.ColumnName) + .Concat(_children.SelectMany(child => child.GetColumnNames())); } } - private sealed class Assignment + private sealed class NestedLeaf { - private readonly Type _targetType; private readonly Action _setter; - - private Assignment(int columnIndex, Type targetType, Action setter) + private readonly Func _converter; + + private NestedLeaf( + PropertyInfo property, + FieldInfo field, + int columnIndex, + string columnName, + string memberPath, + Type targetType, + Action setter) { + Property = property; + Field = field; ColumnIndex = columnIndex; - _targetType = targetType; + ColumnName = columnName; + MemberPath = memberPath; + TargetType = targetType; _setter = setter; + _converter = CreateConverter(targetType); } + internal PropertyInfo Property { get; } + + internal FieldInfo Field { get; } + internal int ColumnIndex { get; } - internal static Assignment ForProperty(int columnIndex, PropertyInfo property) + internal string ColumnName { get; } + + internal string MemberPath { get; } + + internal Type TargetType { get; } + + internal bool CanAssign => _setter != null; + + internal static NestedLeaf ForProperty(PropertyInfo property, int columnIndex, string columnName, string memberPath) + { + return new NestedLeaf( + property, + null, + columnIndex, + columnName, + memberPath, + property.PropertyType, + CreatePropertySetter(property)); + } + + internal static NestedLeaf ForField(FieldInfo field, int columnIndex, string columnName, string memberPath) { - return new Assignment(columnIndex, property.PropertyType, CreatePropertySetter(property)); + return new NestedLeaf( + null, + field, + columnIndex, + columnName, + memberPath, + field.FieldType, + CreateFieldSetter(field)); } - internal static Assignment ForField(int columnIndex, FieldInfo field) + internal object GetValue(IDataRecord record) { - return new Assignment(columnIndex, field.FieldType, CreateFieldSetter(field)); + return _converter(record.GetValue(ColumnIndex)); } internal void Assign(object target, IDataRecord record) { - _setter(target, ConvertValue(record.GetValue(ColumnIndex), _targetType)); + _setter(target, GetValue(record)); + } + } + + private sealed class ConstructorPlan + { + private readonly Type _entityType; + private readonly string _memberPath; + private readonly Func _factory; + private readonly ParameterBinding[] _bindings; + + internal ConstructorPlan( + Type entityType, + string memberPath, + ConstructorInfo constructor, + Func factory, + IEnumerable bindings, + int score) + { + _entityType = entityType; + _memberPath = memberPath; + Constructor = constructor; + _factory = factory; + _bindings = bindings.ToArray(); + Score = score; + } + + internal ConstructorInfo Constructor { get; } + + internal int Score { get; } + + internal bool Uses(NestedLeaf leaf) + { + return _bindings.Any(binding => binding.Leaf == leaf); + } + + internal bool Uses(MaterializationNode child) + { + return _bindings.Any(binding => binding.Child == child); + } + + internal object Create(IDataRecord record) + { + var args = new object[_bindings.Length]; + for (var i = 0; i < _bindings.Length; i++) + { + args[i] = _bindings[i].GetValue(record); + } + + try + { + return _factory(args); + } + catch (Exception exception) + { + throw new FluentMapConfigurationException( + $"Failed to materialize type '{FormatType(Constructor.DeclaringType)}' at member path '{_memberPath}' on entity '{FormatType(_entityType)}' using constructor '{FormatConstructor(Constructor)}'. Columns: {FormatColumns()}. See the inner exception for the domain failure.", + exception); + } + } + + private string FormatColumns() + { + return string.Join(", ", _bindings + .SelectMany(binding => binding.GetColumnNames()) + .Distinct() + .Select(column => "'" + column + "'")); + } + } + + private sealed class ParameterBinding + { + private ParameterBinding(ParameterInfo parameter, NestedLeaf leaf, MaterializationNode child, int score) + { + Parameter = parameter; + Leaf = leaf; + Child = child; + Score = score; + } + + internal ParameterInfo Parameter { get; } + + internal NestedLeaf Leaf { get; } + + internal MaterializationNode Child { get; } + + internal int Score { get; } + + internal static ParameterBinding ForLeaf(ParameterInfo parameter, NestedLeaf leaf, int score) + { + return new ParameterBinding(parameter, leaf, null, score); + } + + internal static ParameterBinding ForChild(ParameterInfo parameter, MaterializationNode child, int score) + { + return new ParameterBinding(parameter, null, child, score); + } + + internal object GetValue(IDataRecord record) + { + if (Leaf != null) + { + return Leaf.GetValue(record); + } + + return Child.MaterializeValue(record); + } + + internal IEnumerable GetColumnNames() + { + if (Leaf != null) + { + yield return Leaf.ColumnName; + yield break; + } + + foreach (var columnName in Child.GetColumnNames()) + { + yield return columnName; + } } } } diff --git a/src/Dapper.FluentMap/QueryMappedExtensions.cs b/src/Dapper.FluentMap/QueryMappedExtensions.cs index c2b8bf4..cb05802 100644 --- a/src/Dapper.FluentMap/QueryMappedExtensions.cs +++ b/src/Dapper.FluentMap/QueryMappedExtensions.cs @@ -13,7 +13,7 @@ namespace Dapper.FluentMap public static class QueryMappedExtensions { private const DynamicallyAccessedMemberTypes MaterializedEntityMemberTypes = - DynamicallyAccessedMemberTypes.PublicParameterlessConstructor | + DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties; private const string QueryMappedRequiresUnreferencedCodeMessage = diff --git a/test/Dapper.FluentMap.AotSmoke/Program.cs b/test/Dapper.FluentMap.AotSmoke/Program.cs index ee74b7f..87e6832 100644 --- a/test/Dapper.FluentMap.AotSmoke/Program.cs +++ b/test/Dapper.FluentMap.AotSmoke/Program.cs @@ -2,6 +2,7 @@ using System.Linq; using Dapper; using Dapper.FluentMap; +using Dapper.FluentMap.Diagnostics; using Dapper.FluentMap.Mapping; using Dapper.FluentMap.Naming; @@ -17,6 +18,7 @@ AssertMappedMember("created_at", nameof(NamingCustomer.CreatedAt)); AssertConstructorMapping(); AssertExplain(); +AssertValueObjectExplain(); #elif AOT_SMOKE_SCANNING const string scenario = "scanning"; FluentMapper.Initialize(configuration => configuration.AddMapsFromAssemblyContaining()); @@ -28,6 +30,7 @@ { configuration.AddMap(); configuration.AddMap(); + configuration.AddMap(); configuration.UseNamingPolicy(NamingPolicy.SnakeCase).ForEntity(); }); @@ -35,6 +38,7 @@ AssertMappedMember("created_at", nameof(NamingCustomer.CreatedAt)); AssertConstructorMapping(); AssertExplain(); +AssertValueObjectExplain(); #endif Console.WriteLine(scenario + ":ok"); @@ -79,6 +83,18 @@ static void AssertExplain() throw new InvalidOperationException("Explain did not include the explicit mapping."); } } + +static void AssertValueObjectExplain() +{ + var explanation = FluentMapper.Explain(); + if (!explanation.Members.Any(member => + member.MemberPath == "Cpf.Number" && + member.ColumnName == "cpf" && + member.Materialization == MappingMaterialization.ValueObject)) + { + throw new InvalidOperationException("Explain did not include the value object mapping."); + } +} #endif public sealed class Customer @@ -122,3 +138,31 @@ public ImmutableCustomerMap() Map(customer => customer.Name).ToColumn("name"); } } + +public sealed class ValueObjectCustomer +{ + public ValueObjectCustomer(Cpf cpf) + { + Cpf = cpf; + } + + public Cpf Cpf { get; } +} + +public sealed class Cpf +{ + public Cpf(string number) + { + Number = number; + } + + public string Number { get; } +} + +public sealed class ValueObjectCustomerMap : EntityMap +{ + public ValueObjectCustomerMap() + { + Map(customer => customer.Cpf.Number).ToColumn("cpf"); + } +} diff --git a/test/Dapper.FluentMap.Tests/NestedMaterializationSpikeTests.cs b/test/Dapper.FluentMap.Tests/NestedMaterializationSpikeTests.cs index 27b83ab..e3553b2 100644 --- a/test/Dapper.FluentMap.Tests/NestedMaterializationSpikeTests.cs +++ b/test/Dapper.FluentMap.Tests/NestedMaterializationSpikeTests.cs @@ -90,19 +90,22 @@ public void TypeHandlerShouldMaterializeScalarValueObjectProperty() [Fact] [Trait("Category", "Integration")] - public void TypeHandlerShouldNotMaterializeNestedValueObjectPath() + public void DapperQueryShouldNotUseTypeHandlerForNestedValueObjectPath() { PreTest(typeof(NestedValueObjectCustomer)); try { SqlMapper.AddTypeHandler(new CpfTypeHandler()); + FluentMapper.Initialize(c => c.AddMap(new NestedValueObjectCustomerMap())); - var exception = Assert.Throws( - () => FluentMapper.Initialize(c => c.AddMap(new NestedValueObjectCustomerMap()))); + using (var connection = OpenConnection()) + { + var customer = connection.QuerySingle( + "SELECT '12345678909' AS cpf;"); - Assert.Contains("Cpf.Number", exception.Message, StringComparison.Ordinal); - Assert.Contains("settable", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Null(customer.Cpf); + } } finally { diff --git a/test/Dapper.FluentMap.Tests/NestedObjectMaterializationTests.cs b/test/Dapper.FluentMap.Tests/NestedObjectMaterializationTests.cs index 5c26c2d..cfe5e89 100644 --- a/test/Dapper.FluentMap.Tests/NestedObjectMaterializationTests.cs +++ b/test/Dapper.FluentMap.Tests/NestedObjectMaterializationTests.cs @@ -343,7 +343,7 @@ public void QueryMappedShouldRejectNestedTypeWithoutPublicParameterlessConstruct var exception = Assert.Throws( () => connection.QueryMappedSingle("SELECT 'Sao Paulo' AS city;")); - Assert.Contains("public parameterless constructor", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("No public constructor", exception.Message, StringComparison.OrdinalIgnoreCase); Assert.Contains("Address", exception.Message, StringComparison.Ordinal); } } @@ -354,17 +354,23 @@ public void QueryMappedShouldRejectNestedTypeWithoutPublicParameterlessConstruct } [Fact] - public void InitializeShouldRejectReadonlyNestedPath() + public void QueryMappedShouldRejectReadonlyNestedPathWithoutMatchingConstructor() { PreTest(typeof(ReadOnlyPathCustomer)); try { - var exception = Assert.Throws( - () => FluentMapper.Initialize(c => c.AddMap(new ReadOnlyPathCustomerMap()))); + FluentMapper.Initialize(c => c.AddMap(new ReadOnlyPathCustomerMap())); - Assert.Contains("settable", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.Contains("Address.City", exception.Message, StringComparison.Ordinal); + using (var connection = OpenConnection()) + { + var exception = Assert.Throws( + () => connection.QueryMappedSingle("SELECT 'Sao Paulo' AS city;")); + + Assert.Contains("No public constructor", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Address", exception.Message, StringComparison.Ordinal); + Assert.Contains("city", exception.Message, StringComparison.Ordinal); + } } finally { diff --git a/test/Dapper.FluentMap.Tests/ValueObjectMaterializationTests.cs b/test/Dapper.FluentMap.Tests/ValueObjectMaterializationTests.cs new file mode 100644 index 0000000..6a77e57 --- /dev/null +++ b/test/Dapper.FluentMap.Tests/ValueObjectMaterializationTests.cs @@ -0,0 +1,808 @@ +using System; +using System.Linq; +using Dapper; +using Dapper.FluentMap.Diagnostics; +using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.Naming; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class ValueObjectMaterializationTests + { + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldMaterializeSimpleValueObjectThroughConstructor() + { + PreTest(typeof(CustomerWithCpf)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerWithCpfMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 1 AS customer_id, '12345678909' AS cpf;"); + + Assert.Equal(1, customer.Id); + Assert.NotNull(customer.Cpf); + Assert.Equal("12345678909", customer.Cpf.Number); + } + } + finally + { + PreTest(typeof(CustomerWithCpf)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldMaterializeSingleValueRecordThroughConstructor() + { + PreTest(typeof(CustomerWithEmail)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerWithEmailMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 2 AS customer_id, 'ada@example.com' AS email;"); + + Assert.Equal(2, customer.Id); + Assert.Equal(new Email("ada@example.com"), customer.Email); + } + } + finally + { + PreTest(typeof(CustomerWithEmail)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldMaterializeMultiComponentValueObjectThroughConstructor() + { + PreTest(typeof(CustomerWithMoney)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerWithMoneyMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 12.50 AS amount, 'BRL' AS currency;"); + + Assert.NotNull(customer.Balance); + Assert.Equal(12.50m, customer.Balance.Amount); + Assert.Equal("BRL", customer.Balance.Currency); + } + } + finally + { + PreTest(typeof(CustomerWithMoney)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldPassNullForNullableValueObjectWhenSqlValueIsNull() + { + PreTest(typeof(CustomerWithCpf)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerWithCpfMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 3 AS customer_id, NULL AS cpf;"); + + Assert.Equal(3, customer.Id); + Assert.Null(customer.Cpf); + } + } + finally + { + PreTest(typeof(CustomerWithCpf)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldWrapDomainExceptionWithMappingContext() + { + PreTest(typeof(CustomerWithCpf)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerWithCpfMap())); + + using (var connection = OpenConnection()) + { + var exception = Assert.Throws( + () => connection.QueryMappedSingle( + "SELECT 4 AS customer_id, '' AS cpf;")); + + Assert.IsType(exception.InnerException); + Assert.Contains(typeof(CustomerWithCpf).FullName, exception.Message); + Assert.Contains(typeof(Cpf).FullName, exception.Message); + Assert.Contains("Cpf", exception.Message); + Assert.Contains("cpf", exception.Message); + } + } + finally + { + PreTest(typeof(CustomerWithCpf)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldMaterializeNestedImmutableObject() + { + PreTest(typeof(CustomerWithImmutableAddress)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerWithImmutableAddressMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 'Sao Paulo' AS city;"); + + Assert.NotNull(customer.Address); + Assert.Equal("Sao Paulo", customer.Address.City); + } + } + finally + { + PreTest(typeof(CustomerWithImmutableAddress)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldMaterializeTwoValueObjectsInSameEntity() + { + PreTest(typeof(CustomerWithTwoCpfs)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerWithTwoCpfsMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT '11111111111' AS cpf, '22222222222' AS backup_cpf;"); + + Assert.Equal("11111111111", customer.Cpf.Number); + Assert.Equal("22222222222", customer.BackupCpf.Number); + } + } + finally + { + PreTest(typeof(CustomerWithTwoCpfs)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldPreserveSameTerminalInImmutablePaths() + { + PreTest(typeof(ImmutableSameTerminalCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new ImmutableSameTerminalCustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 5 AS rank_level, 9 AS seniority_level;"); + + Assert.Equal(5, customer.Rank.Level); + Assert.Equal(9, customer.Seniority.Level); + } + } + finally + { + PreTest(typeof(ImmutableSameTerminalCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldApplyNamingPolicyToImmutableRootConstructor() + { + PreTest(typeof(PolicyValueObjectCustomer)); + + try + { + FluentMapper.Initialize(c => + { + c.UseNamingPolicy(NamingPolicy.SnakeCase, caseSensitive: false).ForEntity(); + c.AddMap(new PolicyValueObjectCustomerMap()); + }); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 6 AS CUSTOMER_ID, 'grace@example.com' AS email;"); + + Assert.Equal(6, customer.CustomerId); + Assert.Equal("grace@example.com", customer.Email.Value); + } + } + finally + { + PreTest(typeof(PolicyValueObjectCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldApplyInheritedValueObjectMapping() + { + PreTest(typeof(BaseCustomerWithCpf), typeof(DerivedCustomerWithCpf)); + + try + { + FluentMapper.Initialize(c => + { + c.AddMap(new BaseCustomerWithCpfMap()); + c.AddMap(new DerivedCustomerWithCpfMap()); + }); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT '33333333333' AS cpf, 'vip' AS tier;"); + + Assert.Equal("33333333333", customer.Cpf.Number); + Assert.Equal("vip", customer.Tier); + } + } + finally + { + PreTest(typeof(BaseCustomerWithCpf), typeof(DerivedCustomerWithCpf)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldMaterializeSimpleImmutableConstructorMapping() + { + PreTest(typeof(SimpleImmutableCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new SimpleImmutableCustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 7 AS customer_id, 'Katherine Johnson' AS full_name;"); + + Assert.Equal(7, customer.Id); + Assert.Equal("Katherine Johnson", customer.FullName); + } + } + finally + { + PreTest(typeof(SimpleImmutableCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldMaterializeValueObjectsAcrossMultipleRows() + { + PreTest(typeof(CustomerWithCpf)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerWithCpfMap())); + + using (var connection = OpenConnection()) + { + var customers = connection.QueryMapped( + "SELECT 8 AS customer_id, '44444444444' AS cpf UNION ALL SELECT 9, '55555555555';") + .ToList(); + + Assert.Collection( + customers, + first => + { + Assert.Equal(8, first.Id); + Assert.Equal("44444444444", first.Cpf.Number); + }, + second => + { + Assert.Equal(9, second.Id); + Assert.Equal("55555555555", second.Cpf.Number); + }); + } + } + finally + { + PreTest(typeof(CustomerWithCpf)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldUseDapperTypeHandlerForScalarValueObjectProperty() + { + PreTest(typeof(HandlerCustomer)); + + try + { + SqlMapper.AddTypeHandler(new CpfTypeHandler()); + FluentMapper.Initialize(c => c.AddMap(new HandlerCustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT '66666666666' AS cpf;"); + + Assert.NotNull(customer.Cpf); + Assert.Equal("66666666666", customer.Cpf.Number); + } + } + finally + { + SqlMapper.ResetTypeHandlers(); + PreTest(typeof(HandlerCustomer)); + } + } + + [Fact] + public void QueryMappedShouldRejectMissingConstructorParameterColumn() + { + PreTest(typeof(CustomerWithIncompleteValueObject)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerWithIncompleteValueObjectMap())); + + using (var connection = OpenConnection()) + { + var exception = Assert.Throws( + () => connection.QueryMappedSingle( + "SELECT '77777777777' AS cpf;")); + + Assert.Contains("No public constructor", exception.Message); + Assert.Contains("IncompleteCpf", exception.Message); + Assert.Contains("cpf", exception.Message); + } + } + finally + { + PreTest(typeof(CustomerWithIncompleteValueObject)); + } + } + + [Fact] + public void QueryMappedShouldRejectAmbiguousValueObjectConstructors() + { + PreTest(typeof(CustomerWithAmbiguousValueObject)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerWithAmbiguousValueObjectMap())); + + using (var connection = OpenConnection()) + { + var exception = Assert.Throws( + () => connection.QueryMappedSingle( + "SELECT 'abc' AS code;")); + + Assert.Contains("multiple public constructors", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("AmbiguousCode", exception.Message); + Assert.Contains("Code", exception.Message); + } + } + finally + { + PreTest(typeof(CustomerWithAmbiguousValueObject)); + } + } + + [Fact] + public void ExplainShouldDescribeValueObjectMaterialization() + { + PreTest(typeof(CustomerWithCpf)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerWithCpfMap())); + + var explanation = FluentMapper.Explain(); + var cpf = explanation.Members.Single(m => m.MemberPath == "Cpf.Number"); + + Assert.Equal("cpf", cpf.ColumnName); + Assert.Equal(MappingSource.Explicit, cpf.Source); + Assert.Equal(MappingMaterialization.ValueObject, cpf.Materialization); + } + finally + { + PreTest(typeof(CustomerWithCpf)); + } + } + + private static SqliteConnection OpenConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + + private static void PreTest(params Type[] types) + { + FluentMapper.Reset(types); + } + + private sealed class CustomerWithCpf + { + public CustomerWithCpf(int id, Cpf cpf) + { + Id = id; + Cpf = cpf; + } + + public int Id { get; } + + public Cpf Cpf { get; } + } + + private sealed class CustomerWithCpfMap : EntityMap + { + public CustomerWithCpfMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Cpf.Number).ToColumn("cpf"); + } + } + + private sealed class Cpf + { + public Cpf(string number) + { + if (string.IsNullOrWhiteSpace(number)) + { + throw new ArgumentException("CPF cannot be empty.", nameof(number)); + } + + Number = number; + } + + public string Number { get; } + } + + private sealed record Email(string Value); + + private sealed class CustomerWithEmail + { + public CustomerWithEmail(int id, Email email) + { + Id = id; + Email = email; + } + + public int Id { get; } + + public Email Email { get; } + } + + private sealed class CustomerWithEmailMap : EntityMap + { + public CustomerWithEmailMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Email.Value).ToColumn("email"); + } + } + + private sealed class Money + { + public Money(decimal amount, string currency) + { + Amount = amount; + Currency = currency; + } + + public decimal Amount { get; } + + public string Currency { get; } + } + + private sealed class CustomerWithMoney + { + public CustomerWithMoney(Money balance) + { + Balance = balance; + } + + public Money Balance { get; } + } + + private sealed class CustomerWithMoneyMap : EntityMap + { + public CustomerWithMoneyMap() + { + Map(customer => customer.Balance.Amount).ToColumn("amount"); + Map(customer => customer.Balance.Currency).ToColumn("currency"); + } + } + + private sealed class CustomerWithImmutableAddress + { + public CustomerWithImmutableAddress(ImmutableAddress address) + { + Address = address; + } + + public ImmutableAddress Address { get; } + } + + private sealed class ImmutableAddress + { + public ImmutableAddress(string city) + { + City = city; + } + + public string City { get; } + } + + private sealed class CustomerWithImmutableAddressMap : EntityMap + { + public CustomerWithImmutableAddressMap() + { + Map(customer => customer.Address.City).ToColumn("city"); + } + } + + private sealed class CustomerWithTwoCpfs + { + public CustomerWithTwoCpfs(Cpf cpf, Cpf backupCpf) + { + Cpf = cpf; + BackupCpf = backupCpf; + } + + public Cpf Cpf { get; } + + public Cpf BackupCpf { get; } + } + + private sealed class CustomerWithTwoCpfsMap : EntityMap + { + public CustomerWithTwoCpfsMap() + { + Map(customer => customer.Cpf.Number).ToColumn("cpf"); + Map(customer => customer.BackupCpf.Number).ToColumn("backup_cpf"); + } + } + + private sealed class ImmutableSameTerminalCustomer + { + public ImmutableSameTerminalCustomer(ImmutableRank rank, ImmutableSeniority seniority) + { + Rank = rank; + Seniority = seniority; + } + + public ImmutableRank Rank { get; } + + public ImmutableSeniority Seniority { get; } + } + + private sealed class ImmutableRank + { + public ImmutableRank(int level) + { + Level = level; + } + + public int Level { get; } + } + + private sealed class ImmutableSeniority + { + public ImmutableSeniority(int level) + { + Level = level; + } + + public int Level { get; } + } + + private sealed class ImmutableSameTerminalCustomerMap : EntityMap + { + public ImmutableSameTerminalCustomerMap() + { + Map(customer => customer.Rank.Level).ToColumn("rank_level"); + Map(customer => customer.Seniority.Level).ToColumn("seniority_level"); + } + } + + private sealed class PolicyValueObjectCustomer + { + public PolicyValueObjectCustomer(int customerId, Email email) + { + CustomerId = customerId; + Email = email; + } + + public int CustomerId { get; } + + public Email Email { get; } + } + + private sealed class PolicyValueObjectCustomerMap : EntityMap + { + public PolicyValueObjectCustomerMap() + { + Map(customer => customer.Email.Value).ToColumn("email"); + } + } + + private class BaseCustomerWithCpf + { + public BaseCustomerWithCpf(Cpf cpf) + { + Cpf = cpf; + } + + public Cpf Cpf { get; } + } + + private sealed class DerivedCustomerWithCpf : BaseCustomerWithCpf + { + public DerivedCustomerWithCpf(Cpf cpf, string tier) + : base(cpf) + { + Tier = tier; + } + + public string Tier { get; } + } + + private sealed class BaseCustomerWithCpfMap : EntityMap + { + public BaseCustomerWithCpfMap() + { + Map(customer => customer.Cpf.Number).ToColumn("cpf"); + } + } + + private sealed class DerivedCustomerWithCpfMap : EntityMap + { + public DerivedCustomerWithCpfMap() + { + IncludeBase(); + Map(customer => customer.Tier).ToColumn("tier"); + } + } + + private sealed class SimpleImmutableCustomer + { + public SimpleImmutableCustomer(int id, string fullName) + { + Id = id; + FullName = fullName; + } + + public int Id { get; } + + public string FullName { get; } + } + + private sealed class SimpleImmutableCustomerMap : EntityMap + { + public SimpleImmutableCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.FullName).ToColumn("full_name"); + } + } + + private sealed class HandlerCustomer + { + public Cpf Cpf { get; set; } + } + + private sealed class HandlerCustomerMap : EntityMap + { + public HandlerCustomerMap() + { + Map(customer => customer.Cpf).ToColumn("cpf"); + } + } + + private sealed class CpfTypeHandler : SqlMapper.TypeHandler + { + public override Cpf Parse(object value) + { + return new Cpf((string)value); + } + + public override void SetValue(System.Data.IDbDataParameter parameter, Cpf value) + { + parameter.Value = value == null ? DBNull.Value : value.Number; + } + } + + private sealed class CustomerWithIncompleteValueObject + { + public CustomerWithIncompleteValueObject(IncompleteCpf cpf) + { + Cpf = cpf; + } + + public IncompleteCpf Cpf { get; } + } + + private sealed class IncompleteCpf + { + public IncompleteCpf(string number, string kind) + { + Number = number; + Kind = kind; + } + + public string Number { get; } + + public string Kind { get; } + } + + private sealed class CustomerWithIncompleteValueObjectMap : EntityMap + { + public CustomerWithIncompleteValueObjectMap() + { + Map(customer => customer.Cpf.Number).ToColumn("cpf"); + } + } + + private sealed class CustomerWithAmbiguousValueObject + { + public CustomerWithAmbiguousValueObject(AmbiguousCode code) + { + Code = code; + } + + public AmbiguousCode Code { get; } + } + + private sealed class AmbiguousCode + { + public AmbiguousCode(object value) + { + Value = (string)value; + } + + public AmbiguousCode(IComparable value) + { + Value = value.ToString(); + } + + public string Value { get; } + } + + private sealed class CustomerWithAmbiguousValueObjectMap : EntityMap + { + public CustomerWithAmbiguousValueObjectMap() + { + Map(customer => customer.Code.Value).ToColumn("code"); + } + } + } +} From 51de1a57680fd204399167f010c29238866dd851 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sun, 26 Jul 2026 15:16:58 -0300 Subject: [PATCH 14/20] feat: add query-scoped mapping profiles --- README.md | 45 +- docs/sdd/etapa-5/04-mapping-profiles.md | 390 +++++++++++ docs/sdd/etapa-5/README.md | 63 ++ docs/sdd/etapa-5/decisions.md | 16 + docs/sdd/etapa-5/status.md | 8 +- .../AnalyzerReleases.Unshipped.md | 2 + .../FluentMapConfigurationAnalyzer.cs | 190 +++++- .../AnalyzerReleases.Unshipped.md | 1 + .../MappingRegistrationGenerator.cs | 80 ++- .../Configuration/FluentMapConfiguration.cs | 44 ++ .../Diagnostics/MappingExplanation.cs | 13 + src/Dapper.FluentMap/FluentMapper.cs | 15 + src/Dapper.FluentMap/Mapping/EntityMap.cs | 16 + src/Dapper.FluentMap/MappingCacheKey.cs | 22 +- src/Dapper.FluentMap/MappingProfileKey.cs | 46 ++ src/Dapper.FluentMap/MappingRegistry.cs | 187 +++++- .../MaterializationPlanCacheKey.cs | 15 +- .../NestedMaterializationPlan.cs | 4 +- src/Dapper.FluentMap/QueryMappedExtensions.cs | 274 +++++++- .../FluentMapConfigurationAnalyzerTests.cs | 85 ++- test/Dapper.FluentMap.AotSmoke/Program.cs | 27 + .../GeneratedRegistrationIntegrationTests.cs | 27 +- .../MappingRegistrationGeneratorTests.cs | 77 +++ .../MappingProfileTests.cs | 622 ++++++++++++++++++ 24 files changed, 2207 insertions(+), 62 deletions(-) create mode 100644 docs/sdd/etapa-5/04-mapping-profiles.md create mode 100644 src/Dapper.FluentMap/MappingProfileKey.cs create mode 100644 test/Dapper.FluentMap.Tests/MappingProfileTests.cs diff --git a/README.md b/README.md index 2a6169d..c1391b5 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,37 @@ var customer = connection.QueryMappedSingle( The regular `Dapper.Query()` path continues to handle root properties, conventions, constructor mapping, TypeHandlers and Dapper fallback as before. For scalar Value Objects mapped as a whole, such as `Map(c => c.Cpf).ToColumn("cpf")`, prefer a Dapper `TypeHandler`. For nested paths such as `Map(c => c.Cpf.Number)`, `QueryMapped*` constructs the Value Object through public constructors and preserves domain invariants. Factory methods and generated materializers are not part of this runtime path. +#### Mapping profiles +When the same entity needs different SQL shapes, register an opt-in mapping profile and select it explicitly per query. Profiles do not replace the Dapper type map global for the entity. + +```csharp +public sealed class LegacyCustomerProfile : IMappingProfile +{ +} + +public sealed class LegacyCustomerMap : + EntityMap, + IProfileMap +{ + public LegacyCustomerMap() + { + Map(c => c.Id).ToColumn("id"); + Map(c => c.Name).ToColumn("legal_name"); + } +} + +FluentMapper.Initialize(config => + { + config.AddMap(); + config.AddProfile(); + }); + +var legacyCustomer = connection.QueryMappedSingle( + "SELECT id, legal_name FROM legacy_customer"); +``` + +`connection.Query(...)` and `connection.QueryMapped(...)` continue using the default mapping. Profile selection is tied to the `QueryMapped()` operation, so concurrent queries using different profiles do not mutate `SqlMapper.SetTypeMap`. + **Initialization:** ```csharp FluentMapper.Initialize(config => @@ -258,10 +289,22 @@ FluentMapper.Initialize(config => ## Resultado da Etapa 4 -- Tooling disponivel: `Dapper.FluentMap.Analyzers` com diagnostics `DFM001` a `DFM005` e `Dapper.FluentMap.Generators` com `AddGeneratedMappings()`, `DFM006` e `DFM007`. +- Tooling disponivel: `Dapper.FluentMap.Analyzers` com diagnostics `DFM001` a `DFM005` e `Dapper.FluentMap.Generators` com `AddGeneratedMappings()`, `DFM006`, `DFM007` e `DFM008`. - Trimming: registro explicito e registro gerado foram validados em smoke trimmed sem warnings FluentMap-owned; assembly scanning permanece reflection-dependent e trimming-sensitive. - Native AOT: publish continua bloqueado neste ambiente por ausencia do platform linker C++; nao ha declaracao de runtime AOT completo. - Caminhos de registro: manual, gerado e assembly scanning coexistem; nenhum caminho antigo foi removido. - Packaging: analyzer e generator ficam em `analyzers/dotnet/cs`; o core continua `netstandard2.0` sem dependencias Roslyn runtime. - Limitacoes: o generator descobre apenas maps da compilacao atual e nao resolve nested object materialization, Value Objects complexos, multiple mapping profiles, query-specific mappings, custom materializer ou generated `DbDataReader` materializer. - Relatorios: `docs/sdd/etapa-4/01-roslyn-analyzers.md`, `docs/sdd/etapa-4/02-trimming-aot.md`, `docs/sdd/etapa-4/03-source-generator.md`. + +## Resultado da Etapa 5 + +- Nested object materialization e Value Objects imutaveis sao suportados no caminho opt-in `QueryMapped*`, com null semantics por subarvore e construcao por construtores publicos. +- TypeHandlers do Dapper continuam sendo o caminho recomendado para Value Objects escalares mapeados como propriedade inteira. +- Mapping profiles foram adicionados por marker tipado (`IMappingProfile` + `IProfileMap`) e selecionados explicitamente por operacao em `QueryMapped()`. +- O mapping default permanece compativel com `Dapper.Query()`; profiles nao trocam `SqlMapper.SetTypeMap` temporariamente. +- Concorrencia sync e async foi validada para profiles distintos sem vazamento de mapping. +- `Explain()`, analyzer e source generator foram atualizados para distinguir default e profiles. +- `QueryMapped*` permanece reflection-based e anotado para trimming/AOT; o generator atual gera registro, nao materializer de `DbDataReader`. +- Limitacoes principais: sem per-profile conventions, sem multi-mapping com profile, sem streaming unbuffered e sem factory methods para Value Objects. +- Relatorios: `docs/sdd/etapa-5/01-nested-materialization-spike.md`, `docs/sdd/etapa-5/02-nested-object-materialization.md`, `docs/sdd/etapa-5/03-value-objects.md`, `docs/sdd/etapa-5/04-mapping-profiles.md`. diff --git a/docs/sdd/etapa-5/04-mapping-profiles.md b/docs/sdd/etapa-5/04-mapping-profiles.md new file mode 100644 index 0000000..b31131f --- /dev/null +++ b/docs/sdd/etapa-5/04-mapping-profiles.md @@ -0,0 +1,390 @@ +# 04 - Mapping Profiles + +## Specification + +O problema desta entrega e permitir que a mesma entidade seja materializada a partir de shapes SQL distintos sem trocar o `ITypeMap` global do Dapper durante a operacao. + +Exemplo: + +```sql +SELECT customer_id, customer_name +SELECT id, legal_name +``` + +Ambas podem materializar `Customer`, mas exigem mappings diferentes. + +Requisitos preservados: + +- `connection.Query(sql)` continua usando o mapping default registrado por `AddMap(...)`; +- profiles sao opt-in por operacao; +- nenhuma query troca `SqlMapper.SetTypeMap(...)` temporariamente; +- queries simultaneas com profiles diferentes nao vazam mappings; +- nested mappings e Value Objects imutaveis continuam usando o caminho `QueryMapped*`; +- analyzer, source generator e `Explain` distinguem default de profiles. + +## Discovery + +Arquivos analisados: + +- `AGENTS.md` +- `.agents/skills/run-tests/SKILL.md` +- `.agents/skills/dotnet-aot-compat/SKILL.md` +- `docs/sdd/etapa-5/README.md` +- `docs/sdd/etapa-5/status.md` +- `docs/sdd/etapa-5/decisions.md` +- `docs/sdd/etapa-5/01-nested-materialization-spike.md` +- `docs/sdd/etapa-5/02-nested-object-materialization.md` +- `docs/sdd/etapa-5/03-value-objects.md` +- `docs/sdd/etapa-1/04-mapping-registry-cache.md` +- `docs/sdd/etapa-3/01-mapping-registration.md` +- `docs/sdd/etapa-3/03-diagnostics-api.md` +- `docs/sdd/etapa-4/02-trimming-aot.md` +- `docs/sdd/etapa-4/03-source-generator.md` +- `src/Dapper.FluentMap/MappingRegistry.cs` +- `src/Dapper.FluentMap/QueryMappedExtensions.cs` +- `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs` +- `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs` +- `src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs` +- `src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs` + +Entregas anteriores confirmadas: + +```text +01 - Spike nested/value-object -> Concluido, commit ff64f96 +02 - Nested object materialization -> Concluido, commit 2ed4af5 +03 - Value Objects imutaveis -> Concluido, commit 68c9959 +``` + +Fontes primarias do Dapper 2.1.79 analisadas: + +- `SqlMapper.ITypeMap.cs`: https://github.com/DapperLib/Dapper/blob/72a54c475f75e18cb93cba0809d00a5e6e49efd9/Dapper/SqlMapper.ITypeMap.cs +- `SqlMapper.cs`: https://github.com/DapperLib/Dapper/blob/72a54c475f75e18cb93cba0809d00a5e6e49efd9/Dapper/SqlMapper.cs +- `SqlMapper.Async.cs`: https://github.com/DapperLib/Dapper/blob/72a54c475f75e18cb93cba0809d00a5e6e49efd9/Dapper/SqlMapper.Async.cs + +Conclusoes sobre o Dapper: + +- o `ITypeMap` e resolvido por `Type`, nao por operacao; +- `CommandDefinition` transporta SQL, parametros, transaction, timeout, command type, flags e cancellation token, mas nao um type map por query; +- as APIs async do Dapper tambem materializam por `Type` e cache interno do Dapper; +- multi-mapping usa `splitOn` e callbacks de composicao, mas nao representa `MemberPath` nem profile identity; +- nao ha API publica no Dapper 2.1.79 para fornecer `ITypeMap` ou materializer customizado por operacao. + +## Alternatives + +### A - Mutation scope + +Modelo: + +```text +SetTypeMap(profile A) +Query() +SetTypeMap(profile B) +``` + +Rejeitada. + +Motivos: + +- `SqlMapper.SetTypeMap` altera estado global por `Type`; +- duas queries simultaneas poderiam observar o profile errado; +- async pode suspender e retomar em outro momento enquanto outro profile foi instalado; +- caches internos do Dapper podem ser aquecidos com uma identidade de type map que nao representa a operacao seguinte; +- exigiria lock global por entidade e reduziria concorrencia, alem de continuar vulneravel a consumidores externos chamando Dapper diretamente. + +### B - Query wrapper + +Aceita como superficie publica. + +`QueryMapped*` ja era a API opt-in da Etapa 5 para materializacao controlada pelo FluentMap. Esta entrega a estende com overloads tipados por profile. + +### C - Custom materializer da Etapa 5 + +Aceita como implementacao. + +`NestedMaterializationPlan` ja controla `DbDataReader`, `MemberPath`, null semantics, construtores e Value Objects. Profiles passam a selecionar outro conjunto de mappings antes de criar/cachear o plano. + +### D - Generated query/materializer + +Adiada. + +O source generator atual gera registro, nao leitura de `DbDataReader`. Materializers gerados continuam sendo o caminho futuro preferencial para performance e Native AOT, mas nao sao necessarios para entregar selecao query-scoped segura. + +### E - Dapper API publica existente + +Nao encontrada no Dapper 2.1.79. + +As APIs publicas de `Query`, `QueryAsync`, `ExecuteReader`, `ExecuteReaderAsync`, `CommandDefinition` e multi-mapping nao aceitam type map/materializer por operacao. + +## Decision + +API escolhida: + +```csharp +public interface IMappingProfile +{ +} + +public interface IProfileMap + where TProfile : IMappingProfile +{ +} + +configuration.AddProfile(); + +connection.QueryMapped(sql); +connection.QueryMappedSingle(sql); +connection.QueryMappedAsync(sql); +connection.QueryMappedSingleAsync(sql); + +FluentMapper.Explain(); +``` + +Exemplo: + +```csharp +public sealed class LegacyProfile : IMappingProfile +{ +} + +public sealed class LegacyCustomerMap : + EntityMap, + IProfileMap +{ + public LegacyCustomerMap() + { + Map(customer => customer.Id).ToColumn("id"); + Map(customer => customer.Name).ToColumn("legal_name"); + } +} +``` + +Identidade do profile: + +- fortemente tipada por marker `TProfile`; +- um map de profile implementa exatamente um `IProfileMap`; +- a entidade continua sendo inferida por `IEntityMap`; +- strings nao foram usadas para evitar typos silenciosos. + +Modelo de registry: + +```text +EntityType + Default map: EntityMaps[EntityType] + Profile maps: ProfileMaps[(EntityType, ProfileType)] + Conventions/naming policies: TypeConventions[EntityType] +``` + +Precedencia efetiva no caminho de profile: + +```text +Profile explicit +Profile inherited no mesmo TProfile +Entity conventions/naming policies atuais +Dapper/default behavior +``` + +Conventions e naming policies continuam registradas por entidade, nao por profile, nesta entrega. Elas sao aplicadas de forma read-only tambem em profiles e podem ser sobrescritas por mappings explicitos do profile. Per-profile conventions ficam como divida futura, porque exigem uma API adicional e regras proprias de composicao. + +Inheritance: + +- `IncludeBase()` em um default map continua procurando o default map da base; +- `IncludeBase()` em um profile map procura a base no mesmo `TProfile`; +- nao ha mistura silenciosa de default base map dentro de um profile alternativo. + +Cache: + +- `MappingCacheKey` agora inclui `ProfileType`; +- `MaterializationPlanCacheKey` agora inclui `ProfileType`; +- o profile e resolvido antes do loop de leitura; +- nao ha lookup textual por row; +- registro/reset invalidam planos por entidade. + +Thread/async safety: + +- a selecao de profile esta nos generics da operacao; +- nenhum `AsyncLocal`, thread-static ou mutacao global e usado para selecionar profile; +- o Dapper type map global continua representando apenas o default; +- `QueryMappedAsync*` usa `CommandDefinition` e `ExecuteReaderAsync`, preservando cancellation token quando o consumidor passa o command overload. + +Compatibilidade: + +- `AddMap(...)`, `AddMap()`, `Dapper.Query()`, `QueryMapped()` e `Explain()` foram preservados; +- `Dapper.Query()` nao ve profiles; +- profiles nao sao registrados por `SqlMapper.SetTypeMap`. + +Limitacoes: + +- profiles sao suportados no caminho `QueryMapped*`, nao em `Dapper.Query()`; +- multi-mapping do Dapper nao recebeu overload de profile; +- unbuffered streaming nao foi implementado; os overloads retornam lista materializada como o `QueryMapped()` existente; +- per-profile conventions/naming policies ficam para etapa futura; +- generator continua sendo registration generator, nao materializer generator. + +## Delivery + +Arquivos adicionados: + +- `src/Dapper.FluentMap/MappingProfileKey.cs` +- `test/Dapper.FluentMap.Tests/MappingProfileTests.cs` +- `docs/sdd/etapa-5/04-mapping-profiles.md` + +Arquivos alterados: + +- `README.md` +- `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs` +- `src/Dapper.FluentMap/Diagnostics/MappingExplanation.cs` +- `src/Dapper.FluentMap/FluentMapper.cs` +- `src/Dapper.FluentMap/Mapping/EntityMap.cs` +- `src/Dapper.FluentMap/MappingCacheKey.cs` +- `src/Dapper.FluentMap/MappingRegistry.cs` +- `src/Dapper.FluentMap/Materialization/MaterializationPlanCacheKey.cs` +- `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs` +- `src/Dapper.FluentMap/QueryMappedExtensions.cs` +- `src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Unshipped.md` +- `src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs` +- `src/Dapper.FluentMap.Generators/AnalyzerReleases.Unshipped.md` +- `src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs` +- `test/Dapper.FluentMap.Analyzers.Tests/FluentMapConfigurationAnalyzerTests.cs` +- `test/Dapper.FluentMap.AotSmoke/Program.cs` +- `test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs` +- `test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs` +- `docs/sdd/etapa-5/README.md` +- `docs/sdd/etapa-5/status.md` +- `docs/sdd/etapa-5/decisions.md` + +Analyzer: + +- `DFM009`: `AddProfile()` com map sem exatamente um `IEntityMap` e um `IProfileMap`; +- `DFM010`: duas chamadas conhecidas ao mesmo entity/profile no mesmo metodo de configuracao; +- IDs existentes `DFM001` a `DFM005` foram preservados. + +Source generator: + +- default maps continuam gerando `.AddMap()`; +- profile maps geram `.AddProfile()`; +- `DFM007` continua valendo apenas para mais de um default map da mesma entidade; +- `DFM008` detecta mais de um generated profile map para a mesma entidade e o mesmo profile. + +## Tests + +Testes novos cobrem: + +- default mapping via `Dapper.Query()`; +- profile alternativo via `QueryMapped()`; +- duas queries sequenciais com profiles diferentes; +- default depois de profile; +- queries paralelas sync com profiles distintos; +- queries async concorrentes com profiles distintos; +- nested mappings em profiles; +- Value Objects em profiles; +- inheritance no mesmo profile marker; +- naming policy de entidade aplicada no profile; +- constructor mapping em profile; +- profile inexistente; +- duplicidade de profile; +- `Explain()`; +- generator com profile; +- generator rejeitando profile duplicado; +- analyzer validando `AddProfile()`; +- analyzer rejeitando registro duplicado conhecido. + +## Performance + +Comparacao arquitetural: + +| Caminho | Resolucao por operacao | Hot path por row | +|---|---|---| +| Dapper/FluentMap default | Dapper resolve type map/cache por `Type` e shape | IL/materializer do Dapper | +| QueryMapped default | plano cacheado por entidade + colunas | delegates precomputados | +| QueryMapped profile | plano cacheado por entidade + profile + colunas | delegates precomputados | + +Overhead esperado do profile: + +- uma chave de cache maior; +- um lookup de `ProfileMaps[(EntityType, ProfileType)]` ao criar o plano; +- nenhum lookup extra por row em relacao ao `QueryMapped()` default. + +Nao foi adicionado benchmark formal nesta entrega. O criterio de aceite foi garantir ausencia de vazamento em concorrencia e evitar resolucao textual por row. + +## AOT And Trimming + +- `QueryMapped*` continua anotado com `RequiresUnreferencedCode` e `RequiresDynamicCode`, porque usa reflection e expression compilation; +- `AddProfile()` segue o mesmo modelo de `AddMap()`, com inferencia por interfaces anotada; +- source generation de registro suporta profiles e evita assembly scanning; +- smoke AOT/trimming valida registro/explain de profile nos caminhos explicit e generated; +- Native AOT runtime completo permanece nao validado no ambiente por ausencia do platform linker C++. + +## Validation + +Validacoes localizadas ja executadas durante a implementacao: + +```text +dotnet build .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Debug +dotnet build .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Debug +dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~MappingProfileTests" +dotnet test .\test\Dapper.FluentMap.Analyzers.Tests\Dapper.FluentMap.Analyzers.Tests.csproj +dotnet test .\test\Dapper.FluentMap.Generators.Tests\Dapper.FluentMap.Generators.Tests.csproj +dotnet test .\test\Dapper.FluentMap.GeneratedRegistration.Tests\Dapper.FluentMap.GeneratedRegistration.Tests.csproj +``` + +Resultados: + +- core: sucesso, 0 warnings, 0 erros; +- testes do core: sucesso, 0 warnings, 0 erros; +- `MappingProfileTests`: sucesso, 15 testes aprovados; +- analyzer: sucesso, 9 testes aprovados; +- generator: sucesso, 14 testes aprovados; +- generated-registration integration: sucesso, 1 teste aprovado. + +Validacao final completa deve registrar: + +```text +dotnet restore +dotnet build +dotnet test +dotnet build --configuration Release +dotnet test --configuration Release +``` + +Resultado final: + +- `dotnet restore`: sucesso; +- `dotnet build`: sucesso, 0 warnings, 0 erros; +- `dotnet test`: sucesso, 181 testes do core, 7 Dommel, 9 analyzer, 14 generator e 1 generated-registration integration; +- `dotnet build --configuration Release`: sucesso, 0 warnings, 0 erros; +- `dotnet test --configuration Release`: sucesso com os mesmos 212 testes totais; +- `dotnet pack` do core: pacote `Dapper.FluentMap.2.0.0.nupkg` criado; warning legado `NU5125` sobre `PackageLicenseUrl`; +- `dotnet pack` do analyzer e generator: pacotes criados com sucesso. + +Inspecao de pacotes: + +- core contem `lib/netstandard2.0/Dapper.FluentMap.dll` e XML docs; +- generator contem `README.md` e `analyzers/dotnet/cs/Dapper.FluentMap.Generators.dll`, sem `lib/`; +- analyzer contem `README.md` e `analyzers/dotnet/cs/Dapper.FluentMap.Analyzers.dll`, sem `lib/`; +- nenhum pacote contem projetos de teste ou artefatos indevidos. + +Smokes AOT/trimming: + +```text +dotnet run --project .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release +dotnet run --project .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:DefineConstants=AOT_SMOKE_GENERATED +dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishTrimmed=true -p:TrimMode=full -p:PublishAot=false -p:TrimmerSingleWarn=false -p:ILLinkTreatWarningsAsErrors=false +dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishTrimmed=true -p:TrimMode=full -p:PublishAot=false -p:DefineConstants=AOT_SMOKE_GENERATED -p:TrimmerSingleWarn=false -p:ILLinkTreatWarningsAsErrors=false +dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishAot=true -p:TrimmerSingleWarn=false -p:ILLinkTreatWarningsAsErrors=false -p:MSBuildWarningsAsMessages= +``` + +Resultados: + +- AOT smoke explicit: `explicit:ok`; +- AOT smoke generated: `generated:ok`; +- publish trimmed explicit: sucesso, runtime `explicit:ok`, sem warnings FluentMap-owned; warnings restantes pertencem ao Dapper; +- publish trimmed generated: sucesso, runtime `generated:ok`, sem warnings FluentMap-owned; warnings restantes pertencem ao Dapper; +- publish Native AOT explicit: falhou no ambiente com `Platform linker not found`; runtime Native AOT nao foi validado. + +## Semantic Commit + +Mensagem planejada: + +```text +feat: add query-scoped mapping profiles +``` diff --git a/docs/sdd/etapa-5/README.md b/docs/sdd/etapa-5/README.md index e5fe689..23d87a8 100644 --- a/docs/sdd/etapa-5/README.md +++ b/docs/sdd/etapa-5/README.md @@ -70,3 +70,66 @@ Esta etapa nao deve transformar o FluentMap em: 2. 02 - Nested object materialization 3. 03 - Value Objects imutaveis 4. 04 - Mapping profiles + +## Resultado da Etapa 5 + +Capacidades entregues: + +- nested mapping opt-in por `QueryMapped()` e `QueryMappedSingle()`, preservando `Dapper.Query()` para o comportamento default; +- `MemberPath` preservado como identidade completa de paths como `Address.City`, `Rank.Level` e `Seniority.Level`; +- null semantics por subarvore: subarvore toda `NULL` resulta em intermediario/value object `null`; subarvore parcialmente preenchida cria o objeto; +- Value Objects imutaveis e nested immutable objects por construtores publicos compativeis, sem setters privados, fields ou bypass de invariantes; +- strategy de constructor/factory limitada a construtores publicos; factory methods permanecem fora do escopo; +- TypeHandler integration preservada para Value Objects escalares mapeados como propriedade inteira; +- mapping profiles query-scoped por `TProfile : IMappingProfile`, registrados por `AddProfile()` e selecionados por `QueryMapped()`; +- concorrencia validada para profiles distintos em queries sync e async simultaneas, sem troca de `SqlMapper.SetTypeMap`; +- `Explain()` para default e `Explain()` para profile, incluindo `Materialization` e `ProfileType`; +- source generator atualizado para gerar `AddMap()` ou `AddProfile()` conforme o map; +- analyzer atualizado com diagnostics determinaveis para profile invalid/duplicado. + +Compatibilidade: + +- `Dapper.Query()`, `AddMap(...)`, `AddMap()`, conventions, naming policies, constructor mapping simples e fallback do Dapper continuam preservados; +- o core continua `netstandard2.0`; +- Dommel nao recebeu alteracao funcional nesta etapa. + +AOT/trimming: + +- `QueryMapped*` continua runtime/reflection-based e anotado com `RequiresUnreferencedCode` e `RequiresDynamicCode`; +- registro explicito e gerado sao os caminhos recomendados para consumidores trimmed; +- source generation ainda gera registro, nao materializer de `DbDataReader`; +- Native AOT runtime completo nao foi validado neste ambiente por ausencia do platform linker C++. + +Limitacoes: + +- `QueryMapped*` materializa em lista, sem streaming unbuffered; +- profiles nao se aplicam a `Dapper.Query()` nem a multi-mapping do Dapper; +- conventions e naming policies ainda sao por entidade, nao por profile; +- factory methods, private constructors, private setters e field injection continuam fora do contrato; +- materializer gerado permanece futuro. + +## Dividas e proximos passos + +### P0 + +- Nenhum item P0 registrado ao encerrar a Etapa 5. + +### P1 + +- Criar materializer gerado para `DbDataReader`, cobrindo nested mappings, Value Objects e profiles sem reflection no hot path. +- Definir suporte a per-profile conventions/naming policies antes de ampliar a composicao de policies. +- Avaliar streaming/unbuffered para `QueryMapped*` com lifetime claro de connection/reader. + +### P2 + +- Adicionar benchmarks formais comparando Dapper default, `QueryMapped()` e `QueryMapped()`. +- Expandir overloads async/default de `QueryMapped*` de forma simetrica, se houver demanda publica. +- Melhorar diagnostics de profile inexistente em analyzer somente quando a ausencia puder ser comprovada sem falso positivo cross-assembly. +- Avaliar API publica de factory methods para Value Objects com regras de ambiguidade e validacao. + +### Research + +- Investigar Native AOT runtime completo em ambiente com platform linker C++ instalado. +- Avaliar integracao futura com APIs publicas novas do Dapper caso surja suporte a materializer/type map por operacao. +- Avaliar modelo de cache imutavel/snapshot para reduzir dependencia de estado global historico. +- Revisar Dommel em etapa propria para decidir se profiles devem ou nao ser visiveis em integrações CRUD externas. diff --git a/docs/sdd/etapa-5/decisions.md b/docs/sdd/etapa-5/decisions.md index bb025c1..dbce893 100644 --- a/docs/sdd/etapa-5/decisions.md +++ b/docs/sdd/etapa-5/decisions.md @@ -38,3 +38,19 @@ Registre aqui apenas decisoes arquiteturais necessarias as proximas entregas. - Um materializer gerado pode ser uma estrategia futura para performance, trimming e Native AOT, mas nao deve ser acoplado a Entrega 2 como unico caminho. - O caminho runtime/reflection-based de `QueryMapped*` e documentado como menos AOT-friendly e foi anotado com `RequiresUnreferencedCode` e `RequiresDynamicCode`; o caminho gerado deve ser a opcao preferencial para consumidores trimmed/AOT quando existir. - A Entrega 3 nao amplia o generator para materializar `DbDataReader`; o smoke AOT valida registro/diagnostico de Value Object, nao runtime AOT completo de `QueryMapped*`. + +## Mapping Profiles + +- Multiple mapping profiles por tipo sao suportados apenas no caminho opt-in `QueryMapped*`; `Dapper.Query` continua usando o mapping default registrado por `AddMap(...)`. +- A identidade de profile e fortemente tipada por marker `TProfile : IMappingProfile`; maps de profile implementam `IProfileMap`. +- A API de registro escolhida e `configuration.AddProfile()`, inferindo a entidade por `IEntityMap` e o profile por `IProfileMap`. +- A API de consulta escolhida e query-scoped: `QueryMapped(...)`, `QueryMappedSingle(...)`, `QueryMappedAsync(...)` e `QueryMappedSingleAsync(...)`. +- `SqlMapper.SetTypeMap` nao e usado para profiles; o type map global do Dapper permanece representando apenas o default. +- O registry passa a modelar `EntityMaps[EntityType]` para default e `ProfileMaps[(EntityType, ProfileType)]` para profiles. +- `MappingCacheKey` e `MaterializationPlanCacheKey` incluem `ProfileType`, evitando reutilizacao de planos entre profiles. +- `IncludeBase()` dentro de profile map procura a base no mesmo `TProfile`; nao ha heranca silenciosa do default dentro de profile alternativo. +- Conventions e naming policies continuam por entidade e sao aplicadas de forma read-only tambem em profiles; per-profile conventions ficam como divida futura. +- `Explain()` continua descrevendo o default; `Explain()` descreve o profile e expoe `MappingExplanation.ProfileType`. +- O source generator distingue default maps de profile maps: default gera `AddMap()`, profile gera `AddProfile()`; duplicidade de profile gerada usa `DFM008`. +- O analyzer adiciona `DFM009` para `AddProfile()` invalido e `DFM010` para duplicidade conhecida de entity/profile no mesmo metodo de configuracao. +- Profiles nao implementam multi-mapping, streaming unbuffered nem materializer gerado nesta entrega. diff --git a/docs/sdd/etapa-5/status.md b/docs/sdd/etapa-5/status.md index 86d6c64..653c304 100644 --- a/docs/sdd/etapa-5/status.md +++ b/docs/sdd/etapa-5/status.md @@ -1,6 +1,6 @@ | Entrega | Status | Commit | |---|---|---| -| 01 - Spike nested/value-object | Concluido | - | -| 02 - Nested object materialization | Concluido | - | -| 03 - Value Objects imutaveis | Concluido | - | -| 04 - Mapping profiles | Pendente | - | +| 01 - Spike nested/value-object | Concluido | ff64f96 | +| 02 - Nested object materialization | Concluido | 2ed4af5 | +| 03 - Value Objects imutaveis | Concluido | 68c9959 | +| 04 - Mapping profiles | Concluido | 65e5fd3 | diff --git a/src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Unshipped.md b/src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Unshipped.md index 244a9d5..5abf163 100644 --- a/src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Unshipped.md @@ -7,3 +7,5 @@ DFM002 | Dapper.FluentMap.Configuration | Error | Property path is mapped more t DFM003 | Dapper.FluentMap.Configuration | Error | Column is mapped by more than one property path. DFM004 | Dapper.FluentMap.Configuration | Error | Included mapping type must be a base class. DFM005 | Dapper.FluentMap.Configuration | Error | Generic map registration type is invalid. +DFM009 | Dapper.FluentMap.Configuration | Error | Generic profile registration type is invalid. +DFM010 | Dapper.FluentMap.Configuration | Error | Mapping profile is registered more than once. diff --git a/src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs b/src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs index cd46ddd..349798a 100644 --- a/src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs +++ b/src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs @@ -18,6 +18,8 @@ public sealed class FluentMapConfigurationAnalyzer : DiagnosticAnalyzer public const string DuplicateColumnDiagnosticId = "DFM003"; public const string InvalidIncludeBaseDiagnosticId = "DFM004"; public const string InvalidGenericMapRegistrationDiagnosticId = "DFM005"; + public const string InvalidGenericProfileRegistrationDiagnosticId = "DFM009"; + public const string DuplicateProfileRegistrationDiagnosticId = "DFM010"; private const string Category = "Dapper.FluentMap.Configuration"; private const string MappingNamespace = "Dapper.FluentMap.Mapping"; @@ -70,13 +72,34 @@ public sealed class FluentMapConfigurationAnalyzer : DiagnosticAnalyzer isEnabledByDefault: true, description: "AddMap() can only register map types that implement exactly one closed IEntityMap interface whose entity type is a class."); + private static readonly DiagnosticDescriptor InvalidGenericProfileRegistrationRule = new DiagnosticDescriptor( + InvalidGenericProfileRegistrationDiagnosticId, + "Generic profile registration type is invalid", + "Profile map type '{0}' must implement exactly one closed IEntityMap interface and exactly one closed IProfileMap interface", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "AddProfile() can only register map types that implement one entity map interface and one mapping profile interface."); + + private static readonly DiagnosticDescriptor DuplicateProfileRegistrationRule = new DiagnosticDescriptor( + DuplicateProfileRegistrationDiagnosticId, + "Mapping profile is registered more than once", + "Entity '{0}' registers mapping profile '{1}' more than once in this configuration method", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "The same entity/profile pair must not be registered more than once.", + customTags: WellKnownDiagnosticTags.CompilationEnd); + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create( InvalidMapExpressionRule, DuplicateMemberPathRule, DuplicateColumnRule, InvalidIncludeBaseRule, - InvalidGenericMapRegistrationRule); + InvalidGenericMapRegistrationRule, + InvalidGenericProfileRegistrationRule, + DuplicateProfileRegistrationRule); public override void Initialize(AnalysisContext context) { @@ -86,19 +109,25 @@ public override void Initialize(AnalysisContext context) context.RegisterCompilationStartAction(startContext => { var constructorMapInvocations = new ConcurrentBag(); + var profileRegistrations = new ConcurrentBag(); startContext.RegisterSyntaxNodeAction( - nodeContext => AnalyzeInvocation(nodeContext, constructorMapInvocations), + nodeContext => AnalyzeInvocation(nodeContext, constructorMapInvocations, profileRegistrations), SyntaxKind.InvocationExpression); startContext.RegisterCompilationEndAction( - endContext => AnalyzeConstructorMapInvocations(endContext, constructorMapInvocations)); + endContext => + { + AnalyzeConstructorMapInvocations(endContext, constructorMapInvocations); + AnalyzeProfileRegistrations(endContext, profileRegistrations); + }); }); } private static void AnalyzeInvocation( SyntaxNodeAnalysisContext context, - ConcurrentBag constructorMapInvocations) + ConcurrentBag constructorMapInvocations, + ConcurrentBag profileRegistrations) { var invocation = (InvocationExpressionSyntax)context.Node; var method = context.SemanticModel.GetSymbolInfo(invocation, context.CancellationToken).Symbol as IMethodSymbol; @@ -123,6 +152,12 @@ private static void AnalyzeInvocation( if (IsGenericAddMapInvocation(method)) { AnalyzeGenericAddMapInvocation(context, invocation, method); + return; + } + + if (IsGenericAddProfileInvocation(method)) + { + AnalyzeGenericAddProfileInvocation(context, invocation, method, profileRegistrations); } } @@ -218,12 +253,7 @@ private static void AnalyzeGenericAddMapInvocation( return; } - var entityMapInterfaces = mapType.AllInterfaces - .Where(type => IsType(type.OriginalDefinition, MappingNamespace, "IEntityMap`1")) - .ToList(); - - if (entityMapInterfaces.Count == 1 && - entityMapInterfaces[0].TypeArguments[0].TypeKind == TypeKind.Class) + if (TryGetEntityMapInterface(mapType, out _)) { return; } @@ -234,6 +264,43 @@ private static void AnalyzeGenericAddMapInvocation( FormatSymbol(mapType))); } + private static void AnalyzeGenericAddProfileInvocation( + SyntaxNodeAnalysisContext context, + InvocationExpressionSyntax invocation, + IMethodSymbol method, + ConcurrentBag profileRegistrations) + { + if (method.TypeArguments.Length != 1) + { + return; + } + + var mapType = method.TypeArguments[0] as INamedTypeSymbol; + if (mapType == null) + { + return; + } + + if (!TryGetEntityMapInterface(mapType, out var entityType) || + !TryGetProfileMapInterface(mapType, out var profileType)) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidGenericProfileRegistrationRule, + invocation.GetLocation(), + FormatSymbol(mapType))); + return; + } + + if (context.ContainingSymbol != null) + { + profileRegistrations.Add(new ProfileRegistrationInvocation( + context.ContainingSymbol, + entityType, + profileType, + GetInvocationNameLocation(invocation))); + } + } + private static void AnalyzeConstructorMapInvocations( CompilationAnalysisContext context, ConcurrentBag constructorMapInvocations) @@ -306,6 +373,36 @@ private static void ReportDuplicateColumns( } } + private static void AnalyzeProfileRegistrations( + CompilationAnalysisContext context, + ConcurrentBag profileRegistrations) + { + var groups = profileRegistrations + .GroupBy( + registration => registration.ContainingSymbol, + SymbolEqualityComparer.Default); + + foreach (var group in groups) + { + var seen = new Dictionary(StringComparer.Ordinal); + foreach (var registration in group.OrderBy(item => item.Location.SourceSpan.Start)) + { + var key = FormatSymbol(registration.EntityType) + "|" + FormatSymbol(registration.ProfileType); + if (seen.ContainsKey(key)) + { + context.ReportDiagnostic(Diagnostic.Create( + DuplicateProfileRegistrationRule, + registration.Location, + FormatSymbol(registration.EntityType), + FormatSymbol(registration.ProfileType))); + continue; + } + + seen.Add(key, registration); + } + } + } + private static bool ColumnNamesOverlap(MapInvocation left, MapInvocation right) { if (string.Equals(left.ColumnName, right.ColumnName, StringComparison.Ordinal)) @@ -547,6 +644,15 @@ private static bool IsGenericAddMapInvocation(IMethodSymbol method) IsType(method.ContainingType, ConfigurationNamespace, "FluentMapConfiguration"); } + private static bool IsGenericAddProfileInvocation(IMethodSymbol method) + { + return method.Name == "AddProfile" && + method.IsGenericMethod && + method.TypeArguments.Length == 1 && + method.Parameters.Length == 0 && + IsType(method.ContainingType, ConfigurationNamespace, "FluentMapConfiguration"); + } + private static bool IsToColumnInvocation(IMethodSymbol method) { return method.Name == "ToColumn" && @@ -559,6 +665,14 @@ private static bool IsIgnoreInvocation(IMethodSymbol method) return method.Name == "Ignore" && method.Parameters.Length == 0; } + private static Location GetInvocationNameLocation(InvocationExpressionSyntax invocation) + { + var memberAccess = invocation.Expression as MemberAccessExpressionSyntax; + return memberAccess == null + ? invocation.GetLocation() + : memberAccess.Name.GetLocation(); + } + private static INamedTypeSymbol FindEntityType(INamedTypeSymbol mapType) { for (var current = mapType; current != null; current = current.BaseType) @@ -593,6 +707,39 @@ private static bool IsType(INamedTypeSymbol type, string namespaceName, string m type.ContainingNamespace.ToDisplayString() == namespaceName; } + private static bool TryGetEntityMapInterface(INamedTypeSymbol mapType, out INamedTypeSymbol entityType) + { + entityType = null; + var entityMapInterfaces = mapType.AllInterfaces + .Where(type => IsType(type.OriginalDefinition, MappingNamespace, "IEntityMap`1")) + .ToList(); + + if (entityMapInterfaces.Count != 1 || + entityMapInterfaces[0].TypeArguments[0].TypeKind != TypeKind.Class) + { + return false; + } + + entityType = entityMapInterfaces[0].TypeArguments[0] as INamedTypeSymbol; + return entityType != null; + } + + private static bool TryGetProfileMapInterface(INamedTypeSymbol mapType, out INamedTypeSymbol profileType) + { + profileType = null; + var profileMapInterfaces = mapType.AllInterfaces + .Where(type => IsType(type.OriginalDefinition, MappingNamespace, "IProfileMap`1")) + .ToList(); + + if (profileMapInterfaces.Count != 1) + { + return false; + } + + profileType = profileMapInterfaces[0].TypeArguments[0] as INamedTypeSymbol; + return profileType != null; + } + private static string FormatSymbol(ISymbol symbol) { return symbol.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat); @@ -662,5 +809,28 @@ internal static MemberPathInfo Create(IEnumerable properties) return new MemberPathInfo(key, display, propertyList[propertyList.Count - 1].Name); } } + + private sealed class ProfileRegistrationInvocation + { + internal ProfileRegistrationInvocation( + ISymbol containingSymbol, + INamedTypeSymbol entityType, + INamedTypeSymbol profileType, + Location location) + { + ContainingSymbol = containingSymbol; + EntityType = entityType; + ProfileType = profileType; + Location = location; + } + + internal ISymbol ContainingSymbol { get; } + + internal INamedTypeSymbol EntityType { get; } + + internal INamedTypeSymbol ProfileType { get; } + + internal Location Location { get; } + } } } diff --git a/src/Dapper.FluentMap.Generators/AnalyzerReleases.Unshipped.md b/src/Dapper.FluentMap.Generators/AnalyzerReleases.Unshipped.md index 2698ff8..a58bc16 100644 --- a/src/Dapper.FluentMap.Generators/AnalyzerReleases.Unshipped.md +++ b/src/Dapper.FluentMap.Generators/AnalyzerReleases.Unshipped.md @@ -7,3 +7,4 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- DFM006 | Dapper.FluentMap.Configuration | Info | Entity map type is skipped by generated registration DFM007 | Dapper.FluentMap.Configuration | Error | Multiple generated entity maps target the same entity +DFM008 | Dapper.FluentMap.Configuration | Error | Multiple generated profile maps target the same entity and profile diff --git a/src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs b/src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs index 41a0bab..ab9e87e 100644 --- a/src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs +++ b/src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs @@ -16,6 +16,7 @@ public sealed class MappingRegistrationGenerator : IIncrementalGenerator public const string InvalidGenericMapRegistrationDiagnosticId = "DFM005"; public const string SkippedGeneratedMapDiagnosticId = "DFM006"; public const string DuplicateGeneratedEntityMapDiagnosticId = "DFM007"; + public const string DuplicateGeneratedProfileMapDiagnosticId = "DFM008"; private const string Category = "Dapper.FluentMap.Configuration"; private const string MappingNamespace = "Dapper.FluentMap.Mapping"; @@ -48,6 +49,15 @@ public sealed class MappingRegistrationGenerator : IIncrementalGenerator isEnabledByDefault: true, description: "Generated registration must not register more than one entity map for the same entity."); + private static readonly DiagnosticDescriptor DuplicateGeneratedProfileMapRule = new DiagnosticDescriptor( + DuplicateGeneratedProfileMapDiagnosticId, + "Multiple generated profile maps target the same entity and profile", + "Entity '{0}' has multiple generated maps for profile '{1}': '{2}' and '{3}'", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Generated registration must not register more than one map for the same entity and mapping profile."); + private static readonly SymbolDisplayFormat FullyQualifiedTypeFormat = new SymbolDisplayFormat( globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, @@ -89,6 +99,9 @@ private static MapCandidate CreateMapCandidate( var entityMapInterfaces = mapType.AllInterfaces .Where(type => IsEntityMapInterface(type)) .ToList(); + var profileMapInterfaces = mapType.AllInterfaces + .Where(type => IsProfileMapInterface(type)) + .ToList(); if (entityMapInterfaces.Count == 0) { @@ -100,12 +113,16 @@ private static MapCandidate CreateMapCandidate( var mapTypeName = mapType.ToDisplayString(FullyQualifiedTypeFormat); if (entityMapInterfaces.Count != 1 || - entityMapInterfaces[0].TypeArguments[0].TypeKind != TypeKind.Class) + entityMapInterfaces[0].TypeArguments[0].TypeKind != TypeKind.Class || + profileMapInterfaces.Count > 1) { return MapCandidate.InvalidRegistration(mapDisplayName, location); } var entityType = (INamedTypeSymbol)entityMapInterfaces[0].TypeArguments[0]; + var profileTypeName = profileMapInterfaces.Count == 0 + ? null + : profileMapInterfaces[0].TypeArguments[0].ToDisplayString(FullyQualifiedTypeFormat); if (mapType.IsAbstract) { return MapCandidate.Skipped(mapDisplayName, location, "the map type is abstract"); @@ -130,6 +147,7 @@ private static MapCandidate CreateMapCandidate( mapDisplayName, mapTypeName, entityType.ToDisplayString(FullyQualifiedTypeFormat), + profileTypeName, GetInheritanceDepth(entityType), location); } @@ -156,8 +174,12 @@ private static void Execute( .ToList(); var duplicateEntityTypeNames = ReportDuplicateEntityMaps(context, validMaps); + var duplicateProfileKeys = ReportDuplicateProfileMaps(context, validMaps); var generatedMaps = validMaps - .Where(candidate => !duplicateEntityTypeNames.Contains(candidate.EntityTypeName)) + .Where(candidate => + candidate.ProfileTypeName == null + ? !duplicateEntityTypeNames.Contains(candidate.EntityTypeName) + : !duplicateProfileKeys.Contains(candidate.ProfileKey)) .ToList(); context.AddSource(GeneratedCodeHintName, SourceText.From(CreateGeneratedSource(generatedMaps), Encoding.UTF8)); @@ -190,6 +212,7 @@ private static ISet ReportDuplicateEntityMaps( { var duplicateEntityTypeNames = new HashSet(StringComparer.Ordinal); var groups = validMaps + .Where(candidate => candidate.ProfileTypeName == null) .GroupBy(candidate => candidate.EntityTypeName, StringComparer.Ordinal) .Where(group => group.Count() > 1); @@ -215,6 +238,39 @@ private static ISet ReportDuplicateEntityMaps( return duplicateEntityTypeNames; } + private static ISet ReportDuplicateProfileMaps( + SourceProductionContext context, + IList validMaps) + { + var duplicateProfileKeys = new HashSet(StringComparer.Ordinal); + var groups = validMaps + .Where(candidate => candidate.ProfileTypeName != null) + .GroupBy(candidate => candidate.ProfileKey, StringComparer.Ordinal) + .Where(group => group.Count() > 1); + + foreach (var group in groups) + { + var orderedGroup = group + .OrderBy(candidate => candidate.MapTypeName, StringComparer.Ordinal) + .ToList(); + var first = orderedGroup[0]; + duplicateProfileKeys.Add(first.ProfileKey); + + foreach (var duplicate in orderedGroup.Skip(1)) + { + context.ReportDiagnostic(Diagnostic.Create( + DuplicateGeneratedProfileMapRule, + duplicate.Location, + duplicate.EntityTypeName, + duplicate.ProfileTypeName, + first.MapTypeName, + duplicate.MapTypeName)); + } + } + + return duplicateProfileKeys; + } + private static string CreateGeneratedSource(IList maps) { var builder = new StringBuilder(); @@ -243,7 +299,9 @@ private static string CreateGeneratedSource(IList maps) for (var index = 0; index < maps.Count; index++) { var terminator = index == maps.Count - 1 ? ";" : string.Empty; - builder.Append(" .AddMap<"); + builder.Append(maps[index].ProfileTypeName == null + ? " .AddMap<" + : " .AddProfile<"); builder.Append(maps[index].MapTypeName); builder.Append(">()"); builder.AppendLine(terminator); @@ -263,6 +321,12 @@ private static bool IsEntityMapInterface(INamedTypeSymbol type) type.OriginalDefinition.ContainingNamespace.ToDisplayString() == MappingNamespace; } + private static bool IsProfileMapInterface(INamedTypeSymbol type) + { + return type.OriginalDefinition.MetadataName == "IProfileMap`1" && + type.OriginalDefinition.ContainingNamespace.ToDisplayString() == MappingNamespace; + } + private static bool ContainsGenericParameters(INamedTypeSymbol type) { if (type.IsGenericType && type.TypeArguments.Any(argument => argument.Kind == SymbolKind.TypeParameter)) @@ -325,6 +389,7 @@ private MapCandidate( string mapDisplayName, string mapTypeName, string entityTypeName, + string profileTypeName, int entityInheritanceDepth, Location location, string skipReason) @@ -333,6 +398,7 @@ private MapCandidate( MapDisplayName = mapDisplayName; MapTypeName = mapTypeName; EntityTypeName = entityTypeName; + ProfileTypeName = profileTypeName; EntityInheritanceDepth = entityInheritanceDepth; Location = location; SkipReason = skipReason; @@ -346,6 +412,10 @@ private MapCandidate( internal string EntityTypeName { get; } + internal string ProfileTypeName { get; } + + internal string ProfileKey => EntityTypeName + "|" + ProfileTypeName; + internal int EntityInheritanceDepth { get; } internal Location Location { get; } @@ -356,6 +426,7 @@ internal static MapCandidate Valid( string mapDisplayName, string mapTypeName, string entityTypeName, + string profileTypeName, int entityInheritanceDepth, Location location) { @@ -364,6 +435,7 @@ internal static MapCandidate Valid( mapDisplayName, mapTypeName, entityTypeName, + profileTypeName, entityInheritanceDepth, location, null); @@ -376,6 +448,7 @@ internal static MapCandidate InvalidRegistration(string mapDisplayName, Location mapDisplayName, null, null, + null, 0, location, null); @@ -388,6 +461,7 @@ internal static MapCandidate Skipped(string mapDisplayName, Location location, s mapDisplayName, null, null, + null, 0, location, reason); diff --git a/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs b/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs index 7f46622..0f0ecf3 100644 --- a/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs +++ b/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs @@ -54,6 +54,25 @@ public FluentMapConfiguration AddMap< return this; } + /// + /// Adds a new instance of the specified entity map type as an explicitly selected mapping profile. + /// + /// The profile entity map type to create and register. + /// The current instance of . + public FluentMapConfiguration AddProfile< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] + TMap>() + where TMap : IEntityMap, new() + { + var mapType = typeof(TMap); + var entityType = GetMappedEntityType(mapType); + var profileType = GetMappedProfileType(mapType); + var mapper = CreateEntityMap(); + + FluentMapper.Registry.AddProfileMap(entityType, profileType, mapper); + return this; + } + /// /// Finds exported entity map types in the specified assembly and adds them to the configuration of Dapper.FluentMap. /// @@ -217,6 +236,31 @@ private static Type GetMappedEntityType( return entityType; } + private static Type GetMappedProfileType( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] + Type mapType) + { + var profileInterfaces = mapType.GetInterfaces() + .Where(type => type.GetTypeInfo().IsGenericType && + type.GetGenericTypeDefinition() == typeof(IProfileMap<>)) + .ToList(); + + if (profileInterfaces.Count != 1) + { + throw new FluentMapConfigurationException( + $"Profile entity map type '{mapType.FullName}' must implement exactly one closed IProfileMap interface."); + } + + var profileType = profileInterfaces[0].GetGenericArguments()[0]; + if (!typeof(IMappingProfile).GetTypeInfo().IsAssignableFrom(profileType.GetTypeInfo())) + { + throw new FluentMapConfigurationException( + $"Profile entity map type '{mapType.FullName}' targets '{profileType.FullName}', but mapping profiles must implement IMappingProfile."); + } + + return profileType; + } + [RequiresUnreferencedCode(AssemblyScanningRequiresUnreferencedCodeMessage)] private static IEntityMap CreateEntityMap(Type mapType) { diff --git a/src/Dapper.FluentMap/Diagnostics/MappingExplanation.cs b/src/Dapper.FluentMap/Diagnostics/MappingExplanation.cs index dc778e6..cd34319 100644 --- a/src/Dapper.FluentMap/Diagnostics/MappingExplanation.cs +++ b/src/Dapper.FluentMap/Diagnostics/MappingExplanation.cs @@ -13,6 +13,7 @@ public sealed class MappingExplanation { internal MappingExplanation( Type entityType, + Type profileType, Type entityMapType, IEnumerable conventionTypes, IEnumerable members, @@ -24,6 +25,7 @@ internal MappingExplanation( } EntityType = entityType; + ProfileType = profileType; EntityMapType = entityMapType; ConventionTypes = new ReadOnlyCollection( (conventionTypes ?? Enumerable.Empty()).ToList()); @@ -38,6 +40,11 @@ internal MappingExplanation( /// public Type EntityType { get; } + /// + /// Gets the mapping profile marker type, when this explanation targets a profile. + /// + public Type ProfileType { get; } + /// /// Gets the registered entity map type, when one exists. /// @@ -63,6 +70,12 @@ public override string ToString() { var builder = new StringBuilder(); builder.Append("Entity: ").Append(EntityType.FullName); + if (ProfileType != null) + { + builder.AppendLine() + .Append("Profile: ") + .Append(ProfileType.FullName); + } foreach (var member in Members) { diff --git a/src/Dapper.FluentMap/FluentMapper.cs b/src/Dapper.FluentMap/FluentMapper.cs index 3d5a006..a96e61e 100644 --- a/src/Dapper.FluentMap/FluentMapper.cs +++ b/src/Dapper.FluentMap/FluentMapper.cs @@ -66,6 +66,21 @@ public static MappingExplanation Explain< return _registry.Explain(typeof(TEntity)); } + /// + /// Explains the effective mapping configuration for the specified entity type and mapping profile. + /// + /// The entity type to explain. + /// The mapping profile marker type to explain. + /// A structured explanation of configured mappings, conventions and fallback mappings. + public static MappingExplanation Explain< + [DynamicallyAccessedMembers(EntityMemberTypes)] + TEntity, + TProfile>() + where TProfile : IMappingProfile + { + return _registry.Explain(typeof(TEntity), typeof(TProfile)); + } + /// /// Registers a Dapper type map using fluent mapping for the specified . /// diff --git a/src/Dapper.FluentMap/Mapping/EntityMap.cs b/src/Dapper.FluentMap/Mapping/EntityMap.cs index 15cbfdc..a66251e 100644 --- a/src/Dapper.FluentMap/Mapping/EntityMap.cs +++ b/src/Dapper.FluentMap/Mapping/EntityMap.cs @@ -30,6 +30,22 @@ public interface IEntityMap< { } + /// + /// Marker interface for a named mapping profile. + /// + public interface IMappingProfile + { + } + + /// + /// Marks an entity map as belonging to the specified mapping profile. + /// + /// The profile marker type. + public interface IProfileMap + where TProfile : IMappingProfile + { + } + internal interface IEntityMapWithIncludedBaseTypes { IList IncludedBaseTypes { get; } diff --git a/src/Dapper.FluentMap/MappingCacheKey.cs b/src/Dapper.FluentMap/MappingCacheKey.cs index 74dad5e..4d7300a 100644 --- a/src/Dapper.FluentMap/MappingCacheKey.cs +++ b/src/Dapper.FluentMap/MappingCacheKey.cs @@ -4,32 +4,41 @@ namespace Dapper.FluentMap { internal struct MappingCacheKey : IEquatable { - private MappingCacheKey(Type type, string columnName, MappingCacheOptions options) + private MappingCacheKey(Type type, Type profileType, string columnName, MappingCacheOptions options) { Type = type; + ProfileType = profileType; ColumnName = columnName; Options = options; } internal Type Type { get; } + internal Type ProfileType { get; } + internal string ColumnName { get; } internal MappingCacheOptions Options { get; } internal static MappingCacheKey FluentMap(Type type, string columnName) { - return new MappingCacheKey(type, columnName, MappingCacheOptions.FluentMap); + return new MappingCacheKey(type, null, columnName, MappingCacheOptions.FluentMap); + } + + internal static MappingCacheKey ProfileMap(Type type, Type profileType, string columnName) + { + return new MappingCacheKey(type, profileType, columnName, MappingCacheOptions.ProfileMap); } internal static MappingCacheKey ConventionOnly(Type type, string columnName) { - return new MappingCacheKey(type, columnName, MappingCacheOptions.ConventionOnly); + return new MappingCacheKey(type, null, columnName, MappingCacheOptions.ConventionOnly); } public bool Equals(MappingCacheKey other) { return Type == other.Type && + ProfileType == other.ProfileType && string.Equals(ColumnName, other.ColumnName, StringComparison.Ordinal) && Options.Equals(other.Options); } @@ -45,6 +54,7 @@ public override int GetHashCode() { var hash = 17; hash = (hash * 31) + (Type == null ? 0 : Type.GetHashCode()); + hash = (hash * 31) + (ProfileType == null ? 0 : ProfileType.GetHashCode()); hash = (hash * 31) + (ColumnName == null ? 0 : ColumnName.GetHashCode()); hash = (hash * 31) + Options.GetHashCode(); return hash; @@ -67,6 +77,9 @@ private MappingCacheOptions(MappingCacheStrategy strategy) internal static MappingCacheOptions ConventionOnly { get; } = new MappingCacheOptions(MappingCacheStrategy.ConventionOnly); + internal static MappingCacheOptions ProfileMap { get; } = + new MappingCacheOptions(MappingCacheStrategy.ProfileMap); + public bool Equals(MappingCacheOptions other) { return _strategy == other._strategy; @@ -86,6 +99,7 @@ public override int GetHashCode() internal enum MappingCacheStrategy { FluentMap, - ConventionOnly + ConventionOnly, + ProfileMap } } diff --git a/src/Dapper.FluentMap/MappingProfileKey.cs b/src/Dapper.FluentMap/MappingProfileKey.cs new file mode 100644 index 0000000..5f73102 --- /dev/null +++ b/src/Dapper.FluentMap/MappingProfileKey.cs @@ -0,0 +1,46 @@ +using System; + +namespace Dapper.FluentMap +{ + internal struct MappingProfileKey : IEquatable + { + internal MappingProfileKey(Type entityType, Type profileType) + { + if (entityType == null) + { + throw new ArgumentNullException(nameof(entityType)); + } + + if (profileType == null) + { + throw new ArgumentNullException(nameof(profileType)); + } + + EntityType = entityType; + ProfileType = profileType; + } + + internal Type EntityType { get; } + + internal Type ProfileType { get; } + + public bool Equals(MappingProfileKey other) + { + return EntityType == other.EntityType && ProfileType == other.ProfileType; + } + + public override bool Equals(object obj) + { + return obj is MappingProfileKey other && Equals(other); + } + + public override int GetHashCode() + { + unchecked + { + return ((EntityType == null ? 0 : EntityType.GetHashCode()) * 397) ^ + (ProfileType == null ? 0 : ProfileType.GetHashCode()); + } + } + } +} diff --git a/src/Dapper.FluentMap/MappingRegistry.cs b/src/Dapper.FluentMap/MappingRegistry.cs index 9cc4997..7552f8b 100644 --- a/src/Dapper.FluentMap/MappingRegistry.cs +++ b/src/Dapper.FluentMap/MappingRegistry.cs @@ -24,6 +24,9 @@ internal sealed class MappingRegistry internal ConcurrentDictionary EntityMaps { get; } = new ConcurrentDictionary(); + internal ConcurrentDictionary ProfileMaps { get; } = + new ConcurrentDictionary(); + internal ConcurrentDictionary> TypeConventions { get; } = new ConcurrentDictionary>(); @@ -55,8 +58,8 @@ internal void AddEntityMap(Type type, IEntityMap mapper) } MappingConfigurationValidator.ValidateEntityMap(type, mapper); - ValidateIncludedBaseMaps(type, mapper); - MappingConfigurationValidator.ValidateComposedEntityMap(type, mapper, ComposeExplicitPropertyMaps(type, mapper)); + ValidateIncludedBaseMaps(type, mapper, profileType: null); + MappingConfigurationValidator.ValidateComposedEntityMap(type, mapper, ComposeExplicitPropertyMaps(type, mapper, profileType: null)); if (!EntityMaps.TryAdd(type, mapper)) { @@ -67,6 +70,46 @@ internal void AddEntityMap(Type type, IEntityMap mapper) SetDapperTypeMap(type); } + internal void AddProfileMap(Type type, Type profileType, IEntityMap mapper) + { + if (type == null) + { + throw new ArgumentNullException(nameof(type)); + } + + if (profileType == null) + { + throw new ArgumentNullException(nameof(profileType)); + } + + if (mapper == null) + { + throw new ArgumentNullException(nameof(mapper)); + } + + var key = new MappingProfileKey(type, profileType); + if (ProfileMaps.ContainsKey(key)) + { + throw new FluentMapConfigurationException( + $"Entity '{type}' already has a configured mapping profile '{profileType}'."); + } + + MappingConfigurationValidator.ValidateEntityMap(type, mapper); + ValidateIncludedBaseMaps(type, mapper, profileType); + MappingConfigurationValidator.ValidateComposedEntityMap( + type, + mapper, + ComposeExplicitPropertyMaps(type, mapper, profileType)); + + if (!ProfileMaps.TryAdd(key, mapper)) + { + throw new FluentMapConfigurationException( + $"Entity '{type}' already has a configured mapping profile '{profileType}'."); + } + + InvalidateType(type); + } + internal void AddConvention(Type type, Convention convention) { if (type == null) @@ -129,6 +172,19 @@ internal IPropertyMap GetFluentPropertyMap(Type type, string columnName) .PropertyMap; } + internal IPropertyMap GetProfilePropertyMap(Type type, Type profileType, string columnName) + { + if (profileType == null) + { + return GetFluentPropertyMap(type, columnName); + } + + var cacheKey = MappingCacheKey.ProfileMap(type, profileType, columnName); + return _propertyMapCache + .GetOrAdd(cacheKey, _ => new MappingCacheEntry(ResolveProfilePropertyMap(type, profileType, columnName))) + .PropertyMap; + } + internal IPropertyMap GetConventionPropertyMap(Type type, string columnName) { var cacheKey = MappingCacheKey.ConventionOnly(type, columnName); @@ -138,6 +194,11 @@ internal IPropertyMap GetConventionPropertyMap(Type type, string columnName) } internal NestedMaterializationPlan GetMaterializationPlan(Type type, string[] columnNames) + { + return GetMaterializationPlan(type, null, columnNames); + } + + internal NestedMaterializationPlan GetMaterializationPlan(Type type, Type profileType, string[] columnNames) { if (type == null) { @@ -149,10 +210,16 @@ internal NestedMaterializationPlan GetMaterializationPlan(Type type, string[] co throw new ArgumentNullException(nameof(columnNames)); } - var cacheKey = new MaterializationPlanCacheKey(type, columnNames); + if (profileType != null && !ProfileMaps.ContainsKey(new MappingProfileKey(type, profileType))) + { + throw new FluentMapConfigurationException( + $"Entity '{type.FullName}' does not have a registered mapping profile '{profileType.FullName}'."); + } + + var cacheKey = new MaterializationPlanCacheKey(type, profileType, columnNames); return _materializationPlanCache.GetOrAdd( cacheKey, - key => NestedMaterializationPlan.Create(key.Type, key.ColumnNames, this)); + key => NestedMaterializationPlan.Create(key.Type, key.ProfileType, key.ColumnNames, this)); } internal void ValidateConfiguration() @@ -164,11 +231,28 @@ internal void ValidateConfiguration() try { MappingConfigurationValidator.ValidateEntityMap(entityMap.Key, entityMap.Value); - ValidateIncludedBaseMaps(entityMap.Key, entityMap.Value); + ValidateIncludedBaseMaps(entityMap.Key, entityMap.Value, profileType: null); MappingConfigurationValidator.ValidateComposedEntityMap( entityMap.Key, entityMap.Value, - ComposeExplicitPropertyMaps(entityMap.Key, entityMap.Value)); + ComposeExplicitPropertyMaps(entityMap.Key, entityMap.Value, profileType: null)); + } + catch (Exception exception) + { + errors.Add(exception.Message); + } + } + + foreach (var profileMap in ProfileMaps.OrderBy(p => p.Key.EntityType.FullName).ThenBy(p => p.Key.ProfileType.FullName)) + { + try + { + MappingConfigurationValidator.ValidateEntityMap(profileMap.Key.EntityType, profileMap.Value); + ValidateIncludedBaseMaps(profileMap.Key.EntityType, profileMap.Value, profileMap.Key.ProfileType); + MappingConfigurationValidator.ValidateComposedEntityMap( + profileMap.Key.EntityType, + profileMap.Value, + ComposeExplicitPropertyMaps(profileMap.Key.EntityType, profileMap.Value, profileMap.Key.ProfileType)); } catch (Exception exception) { @@ -212,6 +296,14 @@ internal void ValidateConfiguration() internal MappingExplanation Explain( [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties)] Type type) + { + return Explain(type, profileType: null); + } + + internal MappingExplanation Explain( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties)] + Type type, + Type profileType) { if (type == null) { @@ -223,11 +315,16 @@ internal MappingExplanation Explain( var configuredPaths = new List(); var entityMapType = default(Type); - if (EntityMaps.TryGetValue(type, out var entityMap)) + IEntityMap entityMap; + var hasEntityMap = profileType == null + ? EntityMaps.TryGetValue(type, out entityMap) + : ProfileMaps.TryGetValue(new MappingProfileKey(type, profileType), out entityMap); + + if (hasEntityMap) { entityMapType = entityMap.GetType(); - foreach (var descriptor in ComposeExplicitPropertyMapDescriptors(type, entityMap)) + foreach (var descriptor in ComposeExplicitPropertyMapDescriptors(type, entityMap, profileType)) { AddMemberExplanation(type, members, configuredPaths, descriptor); } @@ -241,13 +338,18 @@ internal MappingExplanation Explain( AddDapperDefaultExplanations(type, members, configuredPaths); - if (entityMapType == null && conventionTypes.Count == 0) + if (entityMapType == null && profileType != null) + { + diagnostics.Add($"No FluentMap mapping profile '{profileType.FullName}' is registered for this entity. Dapper default mapping is used."); + } + else if (entityMapType == null && conventionTypes.Count == 0) { diagnostics.Add("No FluentMap entity map or convention is registered for this entity. Dapper default mapping is used."); } return new MappingExplanation( type, + profileType, entityMapType, conventionTypes, members.OrderBy(m => m.MemberPath, StringComparer.Ordinal).ThenBy(m => m.ColumnName, StringComparer.Ordinal), @@ -257,6 +359,7 @@ internal MappingExplanation Explain( internal void Reset(params Type[] dapperTypes) { EntityMaps.Clear(); + ProfileMaps.Clear(); TypeConventions.Clear(); _propertyMapCache.Clear(); _materializationPlanCache.Clear(); @@ -293,7 +396,7 @@ private void InvalidateType(Type type) private IPropertyMap ResolveFluentPropertyMap(Type type, string columnName) { - var explicitPropertyMaps = GetExplicitPropertyMaps(type); + var explicitPropertyMaps = GetExplicitPropertyMaps(type, profileType: null); var explicitPropertyMap = explicitPropertyMaps.FirstOrDefault(m => MatchColumnNames(m, columnName)); if (explicitPropertyMap != null) @@ -304,11 +407,34 @@ private IPropertyMap ResolveFluentPropertyMap(Type type, string columnName) return ResolveConventionPropertyMap(type, columnName, explicitPropertyMaps); } - private IList GetExplicitPropertyMaps(Type type) + private IPropertyMap ResolveProfilePropertyMap(Type type, Type profileType, string columnName) { - if (EntityMaps.TryGetValue(type, out var entityMap)) + var explicitPropertyMaps = GetExplicitPropertyMaps(type, profileType); + var explicitPropertyMap = explicitPropertyMaps.FirstOrDefault(m => MatchColumnNames(m, columnName)); + + if (explicitPropertyMap != null) + { + return explicitPropertyMap; + } + + return ResolveConventionPropertyMap(type, columnName, explicitPropertyMaps); + } + + private IList GetExplicitPropertyMaps(Type type, Type profileType) + { + if (profileType == null) { - return ComposeExplicitPropertyMaps(type, entityMap); + if (EntityMaps.TryGetValue(type, out var entityMap)) + { + return ComposeExplicitPropertyMaps(type, entityMap, profileType: null); + } + + return new IPropertyMap[0]; + } + + if (ProfileMaps.TryGetValue(new MappingProfileKey(type, profileType), out var profileMap)) + { + return ComposeExplicitPropertyMaps(type, profileMap, profileType); } return new IPropertyMap[0]; @@ -324,7 +450,7 @@ private IEnumerable GetConventionTypes(Type type) return conventions.Select(c => c.GetType()).ToList(); } - private void ValidateIncludedBaseMaps(Type type, IEntityMap entityMap) + private void ValidateIncludedBaseMaps(Type type, IEntityMap entityMap, Type profileType) { foreach (var baseType in GetIncludedBaseTypes(entityMap)) { @@ -334,22 +460,30 @@ private void ValidateIncludedBaseMaps(Type type, IEntityMap entityMap) $"Type '{baseType.FullName}' cannot be included as a base mapping for entity '{type.FullName}'. The included type must be a base class of the entity."); } - if (!EntityMaps.ContainsKey(baseType)) + var hasBaseMap = profileType == null + ? EntityMaps.ContainsKey(baseType) + : ProfileMaps.ContainsKey(new MappingProfileKey(baseType, profileType)); + + if (!hasBaseMap) { + var profileContext = profileType == null + ? string.Empty + : $" for mapping profile '{profileType.FullName}'"; + throw new FluentMapConfigurationException( - $"Entity '{type.FullName}' includes base mapping '{baseType.FullName}', but no entity map has been registered for the base type. Register the base map before the derived map."); + $"Entity '{type.FullName}' includes base mapping '{baseType.FullName}'{profileContext}, but no entity map has been registered for the base type. Register the base map before the derived map."); } } } - private IList ComposeExplicitPropertyMaps(Type type, IEntityMap entityMap) + private IList ComposeExplicitPropertyMaps(Type type, IEntityMap entityMap, Type profileType) { - return ComposeExplicitPropertyMapDescriptors(type, entityMap) + return ComposeExplicitPropertyMapDescriptors(type, entityMap, profileType) .Select(d => d.Map) .ToList(); } - private IList ComposeExplicitPropertyMapDescriptors(Type type, IEntityMap entityMap) + private IList ComposeExplicitPropertyMapDescriptors(Type type, IEntityMap entityMap, Type profileType) { var propertyMaps = new List(); AddPropertyMapsWithOverride( @@ -358,15 +492,24 @@ private IList ComposeExplicitPropertyMapDescriptors foreach (var baseType in GetIncludedBaseTypes(entityMap)) { - if (!EntityMaps.TryGetValue(baseType, out var baseMap)) + IEntityMap baseMap; + var hasBaseMap = profileType == null + ? EntityMaps.TryGetValue(baseType, out baseMap) + : ProfileMaps.TryGetValue(new MappingProfileKey(baseType, profileType), out baseMap); + + if (!hasBaseMap) { + var profileContext = profileType == null + ? string.Empty + : $" for mapping profile '{profileType.FullName}'"; + throw new FluentMapConfigurationException( - $"Entity '{type.FullName}' includes base mapping '{baseType.FullName}', but no entity map has been registered for the base type. Register the base map before the derived map."); + $"Entity '{type.FullName}' includes base mapping '{baseType.FullName}'{profileContext}, but no entity map has been registered for the base type. Register the base map before the derived map."); } AddPropertyMapsWithOverride( propertyMaps, - ComposeExplicitPropertyMapDescriptors(baseType, baseMap) + ComposeExplicitPropertyMapDescriptors(baseType, baseMap, profileType) .Select(d => d.AsInheritedFrom(baseType))); } diff --git a/src/Dapper.FluentMap/Materialization/MaterializationPlanCacheKey.cs b/src/Dapper.FluentMap/Materialization/MaterializationPlanCacheKey.cs index cafe2c5..513156a 100644 --- a/src/Dapper.FluentMap/Materialization/MaterializationPlanCacheKey.cs +++ b/src/Dapper.FluentMap/Materialization/MaterializationPlanCacheKey.cs @@ -10,7 +10,7 @@ internal sealed class MaterializationPlanCacheKey : IEquatable columnNames) + internal MaterializationPlanCacheKey(Type type, Type profileType, IEnumerable columnNames) { if (type == null) { @@ -23,13 +23,16 @@ internal MaterializationPlanCacheKey(Type type, IEnumerable columnNames) } Type = type; + ProfileType = profileType; _columnNames = columnNames.ToArray(); ColumnNames = new ReadOnlyCollection(_columnNames); - _hashCode = CalculateHashCode(type, _columnNames); + _hashCode = CalculateHashCode(type, profileType, _columnNames); } internal Type Type { get; } + internal Type ProfileType { get; } + internal IReadOnlyList ColumnNames { get; } public bool Equals(MaterializationPlanCacheKey other) @@ -39,7 +42,10 @@ public bool Equals(MaterializationPlanCacheKey other) return true; } - if (other == null || Type != other.Type || _columnNames.Length != other._columnNames.Length) + if (other == null || + Type != other.Type || + ProfileType != other.ProfileType || + _columnNames.Length != other._columnNames.Length) { return false; } @@ -65,11 +71,12 @@ public override int GetHashCode() return _hashCode; } - private static int CalculateHashCode(Type type, string[] columnNames) + private static int CalculateHashCode(Type type, Type profileType, string[] columnNames) { unchecked { var hash = type.GetHashCode(); + hash = (hash * 31) + (profileType == null ? 0 : profileType.GetHashCode()); foreach (var columnName in columnNames) { hash = (hash * 31) + (columnName == null ? 0 : StringComparer.Ordinal.GetHashCode(columnName)); diff --git a/src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs b/src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs index ea2eda5..6e549c4 100644 --- a/src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs +++ b/src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs @@ -18,7 +18,7 @@ private NestedMaterializationPlan(MaterializationNode rootNode) _rootNode = rootNode; } - internal static NestedMaterializationPlan Create(Type entityType, IReadOnlyList columnNames, MappingRegistry registry) + internal static NestedMaterializationPlan Create(Type entityType, Type profileType, IReadOnlyList columnNames, MappingRegistry registry) { if (entityType == null) { @@ -41,7 +41,7 @@ internal static NestedMaterializationPlan Create(Type entityType, IReadOnlyList< for (var i = 0; i < columnNames.Count; i++) { var columnName = columnNames[i]; - var fluentMap = registry.GetFluentPropertyMap(entityType, columnName); + var fluentMap = registry.GetProfilePropertyMap(entityType, profileType, columnName); if (fluentMap != null) { if (fluentMap.Ignored) diff --git a/src/Dapper.FluentMap/QueryMappedExtensions.cs b/src/Dapper.FluentMap/QueryMappedExtensions.cs index cb05802..9c51b76 100644 --- a/src/Dapper.FluentMap/QueryMappedExtensions.cs +++ b/src/Dapper.FluentMap/QueryMappedExtensions.cs @@ -3,7 +3,9 @@ using System.Data; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Threading.Tasks; using Dapper.FluentMap.Materialization; +using Dapper.FluentMap.Mapping; namespace Dapper.FluentMap { @@ -56,19 +58,87 @@ public static IEnumerable QueryMapped< throw new ArgumentNullException(nameof(sql)); } - using (var reader = SqlMapper.ExecuteReader(connection, sql, param, transaction, commandTimeout, commandType)) + return QueryMapped( + connection, + new CommandDefinition(sql, param, transaction, commandTimeout, commandType)); + } + + /// + /// Executes a query and materializes rows using the specified FluentMap mapping profile. + /// + /// The entity type to materialize. + /// The mapping profile marker type to use. + /// The database connection. + /// The SQL query to execute. + /// Optional query parameters. + /// Optional transaction. + /// Optional command timeout. + /// Optional command type. + /// The materialized rows. + [RequiresUnreferencedCode(QueryMappedRequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedRequiresDynamicCodeMessage)] + public static IEnumerable QueryMapped< + [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] + TEntity, + TProfile>( + this IDbConnection connection, + string sql, + object param = null, + IDbTransaction transaction = null, + int? commandTimeout = null, + CommandType? commandType = null) + where TEntity : class + where TProfile : IMappingProfile + { + if (sql == null) { - var columnNames = GetColumnNames(reader); - var plan = FluentMapper.Registry.GetMaterializationPlan(typeof(TEntity), columnNames); - var results = new List(); + throw new ArgumentNullException(nameof(sql)); + } - while (reader.Read()) - { - results.Add((TEntity)plan.Materialize(reader)); - } + return QueryMapped( + connection, + new CommandDefinition(sql, param, transaction, commandTimeout, commandType)); + } - return results; - } + /// + /// Executes a command and materializes rows using FluentMap's opt-in nested object materializer. + /// + /// The entity type to materialize. + /// The database connection. + /// The command to execute. + /// The materialized rows. + [RequiresUnreferencedCode(QueryMappedRequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedRequiresDynamicCodeMessage)] + public static IEnumerable QueryMapped< + [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] + TEntity>( + this IDbConnection connection, + CommandDefinition command) + where TEntity : class + { + return ExecuteMapped(connection, command, profileType: null); + } + + /// + /// Executes a command and materializes rows using the specified FluentMap mapping profile. + /// + /// The entity type to materialize. + /// The mapping profile marker type to use. + /// The database connection. + /// The command to execute. + /// The materialized rows. + [RequiresUnreferencedCode(QueryMappedRequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedRequiresDynamicCodeMessage)] + public static IEnumerable QueryMapped< + [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] + TEntity, + TProfile>( + this IDbConnection connection, + CommandDefinition command) + where TEntity : class + where TProfile : IMappingProfile + { + return ExecuteMapped(connection, command, typeof(TProfile)); } /// @@ -98,6 +168,190 @@ public static TEntity QueryMappedSingle< return QueryMapped(connection, sql, param, transaction, commandTimeout, commandType).Single(); } + /// + /// Executes a query and materializes exactly one row using the specified FluentMap mapping profile. + /// + /// The entity type to materialize. + /// The mapping profile marker type to use. + /// The database connection. + /// The SQL query to execute. + /// Optional query parameters. + /// Optional transaction. + /// Optional command timeout. + /// Optional command type. + /// The materialized row. + [RequiresUnreferencedCode(QueryMappedRequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedRequiresDynamicCodeMessage)] + public static TEntity QueryMappedSingle< + [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] + TEntity, + TProfile>( + this IDbConnection connection, + string sql, + object param = null, + IDbTransaction transaction = null, + int? commandTimeout = null, + CommandType? commandType = null) + where TEntity : class + where TProfile : IMappingProfile + { + return QueryMapped(connection, sql, param, transaction, commandTimeout, commandType).Single(); + } + + /// + /// Executes a query asynchronously and materializes rows using the specified FluentMap mapping profile. + /// + /// The entity type to materialize. + /// The mapping profile marker type to use. + /// The database connection. + /// The SQL query to execute. + /// Optional query parameters. + /// Optional transaction. + /// Optional command timeout. + /// Optional command type. + /// The materialized rows. + [RequiresUnreferencedCode(QueryMappedRequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedRequiresDynamicCodeMessage)] + public static Task> QueryMappedAsync< + [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] + TEntity, + TProfile>( + this IDbConnection connection, + string sql, + object param = null, + IDbTransaction transaction = null, + int? commandTimeout = null, + CommandType? commandType = null) + where TEntity : class + where TProfile : IMappingProfile + { + if (sql == null) + { + throw new ArgumentNullException(nameof(sql)); + } + + return QueryMappedAsync( + connection, + new CommandDefinition(sql, param, transaction, commandTimeout, commandType)); + } + + /// + /// Executes a command asynchronously and materializes rows using the specified FluentMap mapping profile. + /// + /// The entity type to materialize. + /// The mapping profile marker type to use. + /// The database connection. + /// The command to execute. + /// The materialized rows. + [RequiresUnreferencedCode(QueryMappedRequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedRequiresDynamicCodeMessage)] + public static Task> QueryMappedAsync< + [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] + TEntity, + TProfile>( + this IDbConnection connection, + CommandDefinition command) + where TEntity : class + where TProfile : IMappingProfile + { + return ExecuteMappedAsync(connection, command, typeof(TProfile)); + } + + /// + /// Executes a query asynchronously and materializes exactly one row using the specified FluentMap mapping profile. + /// + /// The entity type to materialize. + /// The mapping profile marker type to use. + /// The database connection. + /// The SQL query to execute. + /// Optional query parameters. + /// Optional transaction. + /// Optional command timeout. + /// Optional command type. + /// The materialized row. + [RequiresUnreferencedCode(QueryMappedRequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedRequiresDynamicCodeMessage)] + public static async Task QueryMappedSingleAsync< + [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] + TEntity, + TProfile>( + this IDbConnection connection, + string sql, + object param = null, + IDbTransaction transaction = null, + int? commandTimeout = null, + CommandType? commandType = null) + where TEntity : class + where TProfile : IMappingProfile + { + var rows = await QueryMappedAsync( + connection, + sql, + param, + transaction, + commandTimeout, + commandType).ConfigureAwait(false); + + return rows.Single(); + } + + private static IEnumerable ExecuteMapped< + [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] + TEntity>( + IDbConnection connection, + CommandDefinition command, + Type profileType) + where TEntity : class + { + if (connection == null) + { + throw new ArgumentNullException(nameof(connection)); + } + + using (var reader = SqlMapper.ExecuteReader(connection, command)) + { + return Materialize(reader, profileType); + } + } + + private static async Task> ExecuteMappedAsync< + [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] + TEntity>( + IDbConnection connection, + CommandDefinition command, + Type profileType) + where TEntity : class + { + if (connection == null) + { + throw new ArgumentNullException(nameof(connection)); + } + + using (var reader = await SqlMapper.ExecuteReaderAsync(connection, command).ConfigureAwait(false)) + { + return Materialize(reader, profileType); + } + } + + private static IEnumerable Materialize< + [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] + TEntity>( + IDataReader reader, + Type profileType) + where TEntity : class + { + var columnNames = GetColumnNames(reader); + var plan = FluentMapper.Registry.GetMaterializationPlan(typeof(TEntity), profileType, columnNames); + var results = new List(); + + while (reader.Read()) + { + results.Add((TEntity)plan.Materialize(reader)); + } + + return results; + } + private static string[] GetColumnNames(IDataRecord reader) { var columnNames = new string[reader.FieldCount]; diff --git a/test/Dapper.FluentMap.Analyzers.Tests/FluentMapConfigurationAnalyzerTests.cs b/test/Dapper.FluentMap.Analyzers.Tests/FluentMapConfigurationAnalyzerTests.cs index d09b6fd..c2dc409 100644 --- a/test/Dapper.FluentMap.Analyzers.Tests/FluentMapConfigurationAnalyzerTests.cs +++ b/test/Dapper.FluentMap.Analyzers.Tests/FluentMapConfigurationAnalyzerTests.cs @@ -158,6 +158,76 @@ public void Configure(FluentMapConfiguration configuration) AssertDiagnosticLineContains(source, diagnostic, "configuration.AddMap()"); } + [Fact] + public async Task InvalidGenericProfileRegistrationShouldReportDfm009() + { + var source = @" +using Dapper.FluentMap.Configuration; +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public int Id { get; set; } +} + +public sealed class CustomerMap : EntityMap +{ +} + +public sealed class Startup +{ + public void Configure(FluentMapConfiguration configuration) + { + configuration.AddProfile(); + } +}"; + + var diagnostic = await GetSingleDiagnosticAsync(source, FluentMapConfigurationAnalyzer.InvalidGenericProfileRegistrationDiagnosticId); + + AssertDiagnostic(diagnostic, DiagnosticSeverity.Error, "Profile map type 'CustomerMap' must implement exactly one closed IEntityMap interface and exactly one closed IProfileMap interface"); + AssertDiagnosticLineContains(source, diagnostic, "configuration.AddProfile()"); + } + + [Fact] + public async Task DuplicateProfileRegistrationShouldReportDfm010() + { + var source = @" +using Dapper.FluentMap.Configuration; +using Dapper.FluentMap.Mapping; + +public sealed class LegacyProfile : IMappingProfile +{ +} + +public sealed class Customer +{ + public int Id { get; set; } +} + +public sealed class FirstCustomerMap : EntityMap, IProfileMap +{ +} + +public sealed class SecondCustomerMap : EntityMap, IProfileMap +{ +} + +public sealed class Startup +{ + public void Configure(FluentMapConfiguration configuration) + { + configuration + .AddProfile() + .AddProfile(); + } +}"; + + var diagnostic = await GetSingleDiagnosticAsync(source, FluentMapConfigurationAnalyzer.DuplicateProfileRegistrationDiagnosticId); + + AssertDiagnostic(diagnostic, DiagnosticSeverity.Error, "Entity 'Customer' registers mapping profile 'LegacyProfile' more than once"); + AssertDiagnosticLineContains(source, diagnostic, ".AddProfile()"); + } + [Fact] public async Task ValidMappingConfigurationShouldNotReportDiagnostics() { @@ -218,6 +288,18 @@ public ConstructorCustomerMap() } } +public sealed class LegacyProfile : IMappingProfile +{ +} + +public sealed class LegacyCustomerMap : EntityMap, IProfileMap +{ + public LegacyCustomerMap() + { + Map(c => c.Id).ToColumn(""legacy_customer_id""); + } +} + public sealed class Startup { public void Configure(FluentMapConfiguration configuration) @@ -225,7 +307,8 @@ public void Configure(FluentMapConfiguration configuration) configuration .AddMap() .AddMap() - .AddMap(); + .AddMap() + .AddProfile(); } }"; diff --git a/test/Dapper.FluentMap.AotSmoke/Program.cs b/test/Dapper.FluentMap.AotSmoke/Program.cs index 87e6832..8c0c364 100644 --- a/test/Dapper.FluentMap.AotSmoke/Program.cs +++ b/test/Dapper.FluentMap.AotSmoke/Program.cs @@ -19,6 +19,7 @@ AssertConstructorMapping(); AssertExplain(); AssertValueObjectExplain(); +AssertProfileExplain(); #elif AOT_SMOKE_SCANNING const string scenario = "scanning"; FluentMapper.Initialize(configuration => configuration.AddMapsFromAssemblyContaining()); @@ -31,6 +32,7 @@ configuration.AddMap(); configuration.AddMap(); configuration.AddMap(); + configuration.AddProfile(); configuration.UseNamingPolicy(NamingPolicy.SnakeCase).ForEntity(); }); @@ -39,6 +41,7 @@ AssertConstructorMapping(); AssertExplain(); AssertValueObjectExplain(); +AssertProfileExplain(); #endif Console.WriteLine(scenario + ":ok"); @@ -95,6 +98,18 @@ static void AssertValueObjectExplain() throw new InvalidOperationException("Explain did not include the value object mapping."); } } + +static void AssertProfileExplain() +{ + var explanation = FluentMapper.Explain(); + if (explanation.ProfileType != typeof(LegacyProfile) || + !explanation.Members.Any(member => + member.MemberPath == nameof(Customer.Id) && + member.ColumnName == "legacy_id")) + { + throw new InvalidOperationException("Explain did not include the profile mapping."); + } +} #endif public sealed class Customer @@ -112,6 +127,18 @@ public CustomerMap() } } +public sealed class LegacyProfile : IMappingProfile +{ +} + +public sealed class LegacyCustomerMap : EntityMap, IProfileMap +{ + public LegacyCustomerMap() + { + Map(customer => customer.Id).ToColumn("legacy_id"); + } +} + public sealed class NamingCustomer { public DateTime CreatedAt { get; set; } diff --git a/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs b/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs index acc0546..4aaacb1 100644 --- a/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs +++ b/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs @@ -35,6 +35,8 @@ public void GeneratedRegistrationShouldWorkWithDapperAndExistingMappingFeatures( "SELECT 10 AS immutable_id, 'Grace' AS name;"); var named = connection.QuerySingle( "SELECT '2026-07-26T10:30:00' AS created_at;"); + var profiled = connection.QueryMappedSingle( + "SELECT 11 AS legacy_id, 'Profiled' AS legacy_name;"); Assert.Equal(7, customer.Id); Assert.Equal("Ada", customer.Name); @@ -44,6 +46,8 @@ public void GeneratedRegistrationShouldWorkWithDapperAndExistingMappingFeatures( Assert.Equal(10, immutable.Id); Assert.Equal("Grace", immutable.Name); Assert.Equal(new DateTime(2026, 7, 26, 10, 30, 0), named.CreatedAt); + Assert.Equal(11, profiled.Id); + Assert.Equal("Profiled", profiled.Name); } } finally @@ -67,7 +71,8 @@ private static void ResetMapper() typeof(GeneratedBaseCustomer), typeof(GeneratedDerivedCustomer), typeof(GeneratedImmutableCustomer), - typeof(GeneratedNamingCustomer)); + typeof(GeneratedNamingCustomer), + typeof(GeneratedProfileCustomer)); } } @@ -152,4 +157,24 @@ public sealed class GeneratedNamingCustomer { public DateTime CreatedAt { get; set; } } + + public sealed class GeneratedLegacyProfile : IMappingProfile + { + } + + public sealed class GeneratedProfileCustomer + { + public int Id { get; set; } + + public string Name { get; set; } + } + + public sealed class GeneratedLegacyProfileCustomerMap : EntityMap, IProfileMap + { + public GeneratedLegacyProfileCustomerMap() + { + Map(customer => customer.Id).ToColumn("legacy_id"); + Map(customer => customer.Name).ToColumn("legacy_name"); + } + } } diff --git a/test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs b/test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs index 8dca48c..71ab96f 100644 --- a/test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs +++ b/test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs @@ -218,6 +218,83 @@ public SecondCustomerMap() Assert.Contains("multiple generated entity maps", diagnostic.GetMessage(), StringComparison.Ordinal); } + [Fact] + public void ProfileMappingShouldGenerateAddProfileCall() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class LegacyProfile : IMappingProfile +{ +} + +public sealed class Customer +{ + public int Id { get; set; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(customer => customer.Id).ToColumn(""customer_id""); + } +} + +public sealed class LegacyCustomerMap : EntityMap, IProfileMap +{ + public LegacyCustomerMap() + { + Map(customer => customer.Id).ToColumn(""legacy_id""); + } +}"; + + var result = RunGenerator(source); + + Assert.Empty(result.DfmDiagnostics); + Assert.Contains(".AddMap()", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains(".AddProfile()", result.GeneratedSource, StringComparison.Ordinal); + } + + [Fact] + public void DuplicateProfileMappingsShouldReportDiagnostic() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class LegacyProfile : IMappingProfile +{ +} + +public sealed class Customer +{ + public int Id { get; set; } +} + +public sealed class FirstLegacyCustomerMap : EntityMap, IProfileMap +{ + public FirstLegacyCustomerMap() + { + Map(customer => customer.Id).ToColumn(""legacy_id""); + } +} + +public sealed class SecondLegacyCustomerMap : EntityMap, IProfileMap +{ + public SecondLegacyCustomerMap() + { + Map(customer => customer.Id).ToColumn(""other_legacy_id""); + } +}"; + + var result = RunGenerator(source, assertCompiles: false); + var diagnostic = Assert.Single(result.DfmDiagnostics); + + Assert.Equal(MappingRegistrationGenerator.DuplicateGeneratedProfileMapDiagnosticId, diagnostic.Id); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + Assert.Contains("multiple generated maps for profile", diagnostic.GetMessage(), StringComparison.Ordinal); + } + [Fact] public void DistinctNamespacesShouldGenerateFullyQualifiedNames() { diff --git a/test/Dapper.FluentMap.Tests/MappingProfileTests.cs b/test/Dapper.FluentMap.Tests/MappingProfileTests.cs new file mode 100644 index 0000000..2bb304f --- /dev/null +++ b/test/Dapper.FluentMap.Tests/MappingProfileTests.cs @@ -0,0 +1,622 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Dapper.FluentMap.Diagnostics; +using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.Naming; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class MappingProfileTests + { + [Fact] + [Trait("Category", "Integration")] + public void DapperQueryShouldContinueUsingDefaultMapping() + { + PreTest(typeof(ProfileCustomer)); + + try + { + FluentMapper.Initialize(c => + { + c.AddMap(new DefaultProfileCustomerMap()); + c.AddProfile(); + }); + + using (var connection = OpenConnection()) + { + var customer = connection.QuerySingle( + "SELECT 1 AS customer_id, 'Default' AS customer_name;"); + + Assert.Equal(1, customer.Id); + Assert.Equal("Default", customer.Name); + } + } + finally + { + PreTest(typeof(ProfileCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldUseAlternativeProfile() + { + PreTest(typeof(ProfileCustomer)); + + try + { + FluentMapper.Initialize(c => + { + c.AddMap(new DefaultProfileCustomerMap()); + c.AddProfile(); + }); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 2 AS id, 'Legacy' AS legal_name;"); + + Assert.Equal(2, customer.Id); + Assert.Equal("Legacy", customer.Name); + } + } + finally + { + PreTest(typeof(ProfileCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldUseDifferentProfilesWithoutLeaking() + { + PreTest(typeof(ProfileCustomer)); + + try + { + FluentMapper.Initialize(c => + { + c.AddProfile(); + c.AddProfile(); + }); + + using (var connection = OpenConnection()) + { + var legacy = connection.QueryMappedSingle( + "SELECT 3 AS id, 'Legacy' AS legal_name;"); + var reporting = connection.QueryMappedSingle( + "SELECT 4 AS report_customer_id, 'Reporting' AS report_customer_name;"); + + Assert.Equal(3, legacy.Id); + Assert.Equal("Legacy", legacy.Name); + Assert.Equal(4, reporting.Id); + Assert.Equal("Reporting", reporting.Name); + } + } + finally + { + PreTest(typeof(ProfileCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedDefaultShouldStillUseDefaultAfterProfileQuery() + { + PreTest(typeof(ProfileCustomer)); + + try + { + FluentMapper.Initialize(c => + { + c.AddMap(new DefaultProfileCustomerMap()); + c.AddProfile(); + }); + + using (var connection = OpenConnection()) + { + var profile = connection.QueryMappedSingle( + "SELECT 5 AS id, 'Legacy' AS legal_name;"); + var defaultCustomer = connection.QueryMappedSingle( + "SELECT 6 AS customer_id, 'Default' AS customer_name;"); + + Assert.Equal("Legacy", profile.Name); + Assert.Equal(6, defaultCustomer.Id); + Assert.Equal("Default", defaultCustomer.Name); + } + } + finally + { + PreTest(typeof(ProfileCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldRunParallelProfileQueriesWithoutLeakingMappings() + { + PreTest(typeof(ProfileCustomer)); + + try + { + FluentMapper.Initialize(c => + { + c.AddProfile(); + c.AddProfile(); + }); + + var results = Enumerable.Range(0, 100) + .AsParallel() + .Select(index => + { + using (var connection = OpenConnection()) + { + if (index % 2 == 0) + { + var customer = connection.QueryMappedSingle( + $"SELECT {index} AS id, 'legacy-{index}' AS legal_name;"); + return customer.Id == index && customer.Name == $"legacy-{index}"; + } + + var reporting = connection.QueryMappedSingle( + $"SELECT {index} AS report_customer_id, 'report-{index}' AS report_customer_name;"); + return reporting.Id == index && reporting.Name == $"report-{index}"; + } + }) + .ToList(); + + Assert.All(results, Assert.True); + } + finally + { + PreTest(typeof(ProfileCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public async Task QueryMappedAsyncShouldRunConcurrentProfileQueriesWithoutLeakingMappings() + { + PreTest(typeof(ProfileCustomer)); + + try + { + FluentMapper.Initialize(c => + { + c.AddProfile(); + c.AddProfile(); + }); + + var tasks = Enumerable.Range(0, 40) + .Select(async index => + { + using (var connection = OpenConnection()) + { + if (index % 2 == 0) + { + var customer = await connection.QueryMappedSingleAsync( + $"SELECT {index} AS id, 'legacy-async-{index}' AS legal_name;"); + return customer.Id == index && customer.Name == $"legacy-async-{index}"; + } + + var reporting = await connection.QueryMappedSingleAsync( + $"SELECT {index} AS report_customer_id, 'report-async-{index}' AS report_customer_name;"); + return reporting.Id == index && reporting.Name == $"report-async-{index}"; + } + }) + .ToArray(); + + var results = await Task.WhenAll(tasks); + + Assert.All(results, Assert.True); + } + finally + { + PreTest(typeof(ProfileCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedProfileShouldSupportNestedMappings() + { + PreTest(typeof(ProfileCustomerWithAddress)); + + try + { + FluentMapper.Initialize(c => c.AddProfile()); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 'Sao Paulo' AS legacy_city;"); + + Assert.NotNull(customer.Address); + Assert.Equal("Sao Paulo", customer.Address.City); + } + } + finally + { + PreTest(typeof(ProfileCustomerWithAddress)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedProfileShouldSupportValueObjects() + { + PreTest(typeof(ProfileCustomerWithCpf)); + + try + { + FluentMapper.Initialize(c => c.AddProfile()); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT '12345678909' AS legacy_cpf;"); + + Assert.Equal("12345678909", customer.Cpf.Number); + } + } + finally + { + PreTest(typeof(ProfileCustomerWithCpf)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedProfileShouldUseProfileBaseMappingForInheritance() + { + PreTest(typeof(ProfileBaseCustomer), typeof(ProfileDerivedCustomer)); + + try + { + FluentMapper.Initialize(c => + { + c.AddProfile(); + c.AddProfile(); + }); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 7 AS legacy_id, 'gold' AS legacy_tier;"); + + Assert.Equal(7, customer.Id); + Assert.Equal("gold", customer.Tier); + } + } + finally + { + PreTest(typeof(ProfileBaseCustomer), typeof(ProfileDerivedCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedProfileShouldApplyEntityNamingPolicy() + { + PreTest(typeof(ProfilePolicyCustomer)); + + try + { + FluentMapper.Initialize(c => + { + c.UseNamingPolicy(NamingPolicy.SnakeCase, caseSensitive: false).ForEntity(); + c.AddProfile(); + }); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 8 AS CUSTOMER_ID, 'policy@example.com' AS legacy_email;"); + + Assert.Equal(8, customer.CustomerId); + Assert.Equal("policy@example.com", customer.Email.Value); + } + } + finally + { + PreTest(typeof(ProfilePolicyCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedProfileShouldSupportConstructorMapping() + { + PreTest(typeof(ProfileImmutableCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddProfile()); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 9 AS legacy_id, 'Immutable Legacy' AS legacy_name;"); + + Assert.Equal(9, customer.Id); + Assert.Equal("Immutable Legacy", customer.Name); + } + } + finally + { + PreTest(typeof(ProfileImmutableCustomer)); + } + } + + [Fact] + public void QueryMappedProfileShouldRejectMissingProfile() + { + PreTest(typeof(ProfileCustomer)); + + try + { + using (var connection = OpenConnection()) + { + var exception = Assert.Throws( + () => connection.QueryMappedSingle( + "SELECT 1 AS id, 'Legacy' AS legal_name;")); + + Assert.Contains("does not have a registered mapping profile", exception.Message); + Assert.Contains(typeof(LegacyProfile).FullName, exception.Message); + } + } + finally + { + PreTest(typeof(ProfileCustomer)); + } + } + + [Fact] + public void AddProfileShouldRejectDuplicateProfileForEntity() + { + PreTest(typeof(ProfileCustomer)); + + try + { + var exception = Assert.Throws( + () => FluentMapper.Initialize(c => + { + c.AddProfile(); + c.AddProfile(); + })); + + Assert.Contains("already has a configured mapping profile", exception.Message); + Assert.Contains(typeof(LegacyProfile).FullName, exception.Message); + } + finally + { + PreTest(typeof(ProfileCustomer)); + } + } + + [Fact] + public void AddProfileShouldRejectProfileBaseMappingWhenSameProfileBaseIsMissing() + { + PreTest(typeof(ProfileBaseCustomer), typeof(ProfileDerivedCustomer)); + + try + { + var exception = Assert.Throws( + () => FluentMapper.Initialize(c => c.AddProfile())); + + Assert.Contains(typeof(LegacyProfile).FullName, exception.Message); + Assert.Contains(typeof(ProfileBaseCustomer).FullName, exception.Message); + } + finally + { + PreTest(typeof(ProfileBaseCustomer), typeof(ProfileDerivedCustomer)); + } + } + + [Fact] + public void ExplainShouldDescribeProfileMappings() + { + PreTest(typeof(ProfileCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddProfile()); + + var explanation = FluentMapper.Explain(); + var name = explanation.Members.Single(m => m.MemberPath == nameof(ProfileCustomer.Name)); + + Assert.Equal(typeof(LegacyProfile), explanation.ProfileType); + Assert.Equal(typeof(LegacyProfileCustomerMap), explanation.EntityMapType); + Assert.Equal("legal_name", name.ColumnName); + Assert.Equal(MappingSource.Explicit, name.Source); + } + finally + { + PreTest(typeof(ProfileCustomer)); + } + } + + private static SqliteConnection OpenConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + + private static void PreTest(params Type[] types) + { + FluentMapper.Reset(types); + } + + private sealed class LegacyProfile : IMappingProfile + { + } + + private sealed class ReportingProfile : IMappingProfile + { + } + + private sealed class ProfileCustomer + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private sealed class DefaultProfileCustomerMap : EntityMap + { + public DefaultProfileCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Name).ToColumn("customer_name"); + } + } + + private sealed class LegacyProfileCustomerMap : EntityMap, IProfileMap + { + public LegacyProfileCustomerMap() + { + Map(customer => customer.Id).ToColumn("id"); + Map(customer => customer.Name).ToColumn("legal_name"); + } + } + + private sealed class SecondLegacyProfileCustomerMap : EntityMap, IProfileMap + { + public SecondLegacyProfileCustomerMap() + { + Map(customer => customer.Id).ToColumn("other_id"); + } + } + + private sealed class ReportingProfileCustomerMap : EntityMap, IProfileMap + { + public ReportingProfileCustomerMap() + { + Map(customer => customer.Id).ToColumn("report_customer_id"); + Map(customer => customer.Name).ToColumn("report_customer_name"); + } + } + + private sealed class ProfileCustomerWithAddress + { + public ProfileAddress Address { get; set; } + } + + private sealed class ProfileAddress + { + public string City { get; set; } + } + + private sealed class LegacyProfileCustomerWithAddressMap : EntityMap, IProfileMap + { + public LegacyProfileCustomerWithAddressMap() + { + Map(customer => customer.Address.City).ToColumn("legacy_city"); + } + } + + private sealed class ProfileCustomerWithCpf + { + public ProfileCustomerWithCpf(ProfileCpf cpf) + { + Cpf = cpf; + } + + public ProfileCpf Cpf { get; } + } + + private sealed class ProfileCpf + { + public ProfileCpf(string number) + { + Number = number; + } + + public string Number { get; } + } + + private sealed class LegacyProfileCustomerWithCpfMap : EntityMap, IProfileMap + { + public LegacyProfileCustomerWithCpfMap() + { + Map(customer => customer.Cpf.Number).ToColumn("legacy_cpf"); + } + } + + private class ProfileBaseCustomer + { + public int Id { get; set; } + } + + private sealed class ProfileDerivedCustomer : ProfileBaseCustomer + { + public string Tier { get; set; } + } + + private sealed class LegacyProfileBaseCustomerMap : EntityMap, IProfileMap + { + public LegacyProfileBaseCustomerMap() + { + Map(customer => customer.Id).ToColumn("legacy_id"); + } + } + + private sealed class LegacyProfileDerivedCustomerMap : EntityMap, IProfileMap + { + public LegacyProfileDerivedCustomerMap() + { + IncludeBase(); + Map(customer => customer.Tier).ToColumn("legacy_tier"); + } + } + + private sealed class ProfilePolicyCustomer + { + public ProfilePolicyCustomer(int customerId, ProfileEmail email) + { + CustomerId = customerId; + Email = email; + } + + public int CustomerId { get; } + + public ProfileEmail Email { get; } + } + + private sealed record ProfileEmail(string Value); + + private sealed class LegacyProfilePolicyCustomerMap : EntityMap, IProfileMap + { + public LegacyProfilePolicyCustomerMap() + { + Map(customer => customer.Email.Value).ToColumn("legacy_email"); + } + } + + private sealed class ProfileImmutableCustomer + { + public ProfileImmutableCustomer(int id, string name) + { + Id = id; + Name = name; + } + + public int Id { get; } + + public string Name { get; } + } + + private sealed class LegacyProfileImmutableCustomerMap : EntityMap, IProfileMap + { + public LegacyProfileImmutableCustomerMap() + { + Map(customer => customer.Id).ToColumn("legacy_id"); + Map(customer => customer.Name).ToColumn("legacy_name"); + } + } + } +} From dba5be53f5919fb4a3cc5398424b66cd55211526 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sun, 26 Jul 2026 18:27:49 -0300 Subject: [PATCH 15/20] docs(fluentmap): define configuration lifecycle contract --- README.md | 14 + .../sdd/etapa-6/01-configuration-lifecycle.md | 283 +++++ docs/sdd/etapa-6/README.md | 40 + docs/sdd/etapa-6/decisions.md | 47 + docs/sdd/etapa-6/handoff.md | 81 ++ docs/sdd/fluentmap-risk-assessment.md | 1017 +++++++++++++++++ .../ConfigurationLifecycleTests.cs | 149 +++ 7 files changed, 1631 insertions(+) create mode 100644 docs/sdd/etapa-6/01-configuration-lifecycle.md create mode 100644 docs/sdd/etapa-6/README.md create mode 100644 docs/sdd/etapa-6/decisions.md create mode 100644 docs/sdd/etapa-6/handoff.md create mode 100644 docs/sdd/fluentmap-risk-assessment.md create mode 100644 test/Dapper.FluentMap.Tests/ConfigurationLifecycleTests.cs diff --git a/README.md b/README.md index c1391b5..7958391 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,20 @@ var legacyCustomer = connection.QueryMappedSingle(...)` and `connection.QueryMapped(...)` continue using the default mapping. Profile selection is tied to the `QueryMapped()` operation, so concurrent queries using different profiles do not mutate `SqlMapper.SetTypeMap`. +#### Configuration lifecycle +FluentMap configuration is process-wide because it stores mappings in a global registry and installs default mappings in Dapper's global type-map registry. The supported lifecycle is: + +```text +Configuration Phase + | + v +Operational Phase +``` + +Configure FluentMap during application startup, optionally call `FluentMapper.Validate()`, then treat the effective configuration as read-only once queries begin. `FluentMapper.Initialize(...)` can still be called more than once for additive configuration, subject to the existing duplicate-map validations, but runtime reconfiguration is not a concurrency contract. + +For compatibility, the public registration APIs still mutate the global registry immediately. If an application changes mappings after queries have started, it must guarantee external quiescence for the affected types: no concurrent queries, no active materializers, and no competing `SqlMapper.SetTypeMap` changes. Direct mutation of `FluentMapper.EntityMaps` or `FluentMapper.TypeConventions` is a legacy compatibility surface and can bypass validation, cache invalidation and Dapper type-map installation; prefer `Initialize(...)` and the fluent registration APIs. + **Initialization:** ```csharp FluentMapper.Initialize(config => diff --git a/docs/sdd/etapa-6/01-configuration-lifecycle.md b/docs/sdd/etapa-6/01-configuration-lifecycle.md new file mode 100644 index 0000000..d9622a3 --- /dev/null +++ b/docs/sdd/etapa-6/01-configuration-lifecycle.md @@ -0,0 +1,283 @@ +# 01 - Configuration Lifecycle + +## Current Behavior + +`FluentMapper` e a fachada publica global do core. Ela possui: + +- `_registry`: instancia estatica de `MappingRegistry`; +- `_configuration`: instancia estatica de `FluentMapConfiguration`; +- `EntityMaps`: campo publico `ConcurrentDictionary` apontando para o storage do registry; +- `TypeConventions`: campo publico `ConcurrentDictionary>` apontando para o storage do registry. + +`FluentMapper.Initialize(Action)` nao cria snapshot e nao marca a configuracao como concluida. Ele apenas executa o callback recebido sobre a mesma instancia estatica de `FluentMapConfiguration`. + +As APIs publicas que podem alterar configuracao sao: + +- `FluentMapper.Initialize(...)`; +- `FluentMapConfiguration.AddMap(IEntityMap)`; +- `FluentMapConfiguration.AddMap()`; +- `FluentMapConfiguration.AddProfile()`; +- `FluentMapConfiguration.AddMapsFromAssembly(...)`; +- `FluentMapConfiguration.AddMapsFromAssemblyContaining()`; +- `FluentMapConfiguration.AddConvention()` combinado com `ForEntity(...)`, `ForEntitiesInAssembly(...)` ou `ForEntitiesInCurrentAssembly(...)`; +- `FluentMapConfiguration.UseNamingPolicy(...)` combinado com os mesmos destinos de convention; +- `FluentMapConfigurationExtensions.ApplyMapsFromAssemblies(...)`; +- mutacao direta de `FluentMapper.EntityMaps`; +- mutacao direta de `FluentMapper.TypeConventions`. + +As estruturas static/global atuais sao: + +- `FluentMapper._registry`; +- `FluentMapper._configuration`; +- `FluentMapper.EntityMaps`; +- `FluentMapper.TypeConventions`; +- caches internos de `MappingRegistry`; +- registro global de type maps do Dapper via `SqlMapper.SetTypeMap`; +- cache legado protegido `MultiTypeMap.TypePropertyMapCache`, preservado por compatibilidade mas nao usado pelo core atual. + +`SqlMapper.SetTypeMap` e chamado em: + +- `MappingRegistry.AddEntityMap(...)`, depois de validar e adicionar um default map; +- `MappingRegistry.AddConvention(...)`, depois de adicionar uma convention/naming policy; +- `MappingRegistry.Reset(...)`, para remover type maps dos tipos informados nos testes; +- testes de caracterizacao que instalam type maps customizados diretamente. + +`SqlMapper.SetTypeMap` nao e chamado por `AddProfile()` e nao e chamado por `QueryMapped()`. + +Invalidacao de cache atual: + +- `AddEntityMap(...)` invalida entradas de property-map e materialization-plan cache do tipo e reinstala o type map do Dapper; +- `AddProfileMap(...)` invalida caches do tipo, mas nao troca o type map global do Dapper; +- `AddConvention(...)` invalida caches do tipo e reinstala o type map do Dapper; +- `Reset(...)` limpa maps, profiles, conventions, property-map cache, materialization-plan cache e remove type maps do Dapper para os tipos informados; +- mutacao direta de `EntityMaps` ou `TypeConventions` nao passa pelo registry e pode bypassar validacao, invalidacao e instalacao de type map. + +Profiles evitam mutacao global porque sao armazenados em `MappingRegistry.ProfileMaps[(EntityType, ProfileType)]` e selecionados pelo caminho `QueryMapped()`. A chave de cache inclui `ProfileType`, e o materializer resolve o profile antes do loop de leitura. O default type map do Dapper permanece representando apenas a configuracao default. + +Os testes resetam estado por `FluentMapper.Reset(...)`, que e interno e visivel ao assembly de testes. A suite principal, Dommel e generated-registration desabilitam paralelismo porque FluentMap e Dapper compartilham estado global por processo. + +Nao existe atualmente nenhum conceito publico ou interno de `configuration completed`, `freeze`, `sealed`, `initialized` ou equivalente. Chamadas repetidas de `Initialize(...)` sao permitidas quando adicionam configuracao valida e falham pelas regras existentes quando duplicam default maps ou profiles. + +Nao foi encontrada documentacao historica prometendo runtime reconfiguration concorrente. A documentacao existente recomenda inicializacao por `FluentMapper.Initialize(...)`, valida estado global por `Validate()` e usa profiles query-scoped para evitar troca temporaria de `SqlMapper.SetTypeMap`. + +## Problem + +O FluentMap depende de estado global/static proprio e do registro global de `ITypeMap` do Dapper. Isso e compativel com o uso historico de configurar uma vez no startup e consultar depois, mas e ambiguo para consumidores que interpretam `Initialize(...)`, conventions ou dicionarios publicos como API de reconfiguracao dinamica durante a execucao. + +O risco arquitetural e que queries concorrentes observem configuracoes diferentes para o mesmo tipo, ou que caches internos e o registro global do Dapper sejam alterados enquanto materializers estao em uso. + +## Supported Lifecycle + +O lifecycle suportado passa a ser: + +```text +Configuration Phase + | + v +Operational Phase +``` + +### Configuration Phase + +Fase esperada durante startup da aplicacao ou antes do primeiro uso dos tipos configurados. + +Permitido: + +- registrar default maps; +- registrar profiles; +- registrar conventions e naming policies; +- usar assembly scanning quando apropriado para runtime normal; +- chamar `Validate()`; +- chamar `Explain()` para diagnostico; +- chamar `Initialize(...)` mais de uma vez para configuracao aditiva, desde que cada chamada respeite as regras de duplicidade e validacao existentes. + +### Operational Phase + +Comeca quando a aplicacao passa a executar queries que podem usar FluentMap ou o type map global do Dapper para os tipos configurados. + +Permitido como operacao normal: + +- `Dapper.Query()` usando o default type map ja instalado; +- `QueryMapped()` usando o default registry snapshot efetivo no momento de criar o plano; +- `QueryMapped()` selecionando profile por operacao; +- `Validate()` e `Explain<...>()` como leituras diagnosticas sem side effects intencionais. + +Durante esta fase, consumidores devem tratar a configuracao efetiva como read-only. + +### Compatibility Runtime Mutation + +Por compatibilidade, as APIs publicas atuais continuam podendo registrar maps, profiles e conventions depois de queries ja terem ocorrido. Esse uso e suportado apenas quando o consumidor garante quiescencia externa para os tipos afetados: sem queries concorrentes, sem materializers em execucao e sem outro componente alterando `SqlMapper.SetTypeMap`. + +Nao ha garantia de determinismo para reconfiguracao concorrente em runtime. + +Mutacao direta de `EntityMaps` e `TypeConventions` permanece uma superficie legada de compatibilidade, mas nao faz parte do caminho suportado para configuracao deterministica. Ela pode bypassar validacao, invalidacao de cache e instalacao do type map do Dapper. + +## Invariants + +- Queries nao devem depender de configuracao sendo alterada simultaneamente. +- Configuracao estabelecida antes da fase operacional deve produzir comportamento deterministico. +- `AddMap(...)`, conventions e naming policies aplicadas pelo registry devem invalidar caches do tipo afetado. +- `AddProfile()` deve invalidar planos do tipo, mas nao trocar o type map global do Dapper. +- Profiles devem permanecer query-scoped. +- Dapper global state nao deve ser trocado temporariamente para implementar profiles. +- `Validate()` e `Explain<...>()` devem permanecer leituras diagnosticas sem instalacao de type maps ou invalidacao de caches. +- Compatibilidade existente nao deve ser quebrada silenciosamente. +- Dicionarios publicos mutaveis nao devem ser tratados como caminho recomendado de configuracao nova. + +## Goals + +- Documentar o lifecycle oficial suportado. +- Distinguir configuracao normal, operacao read-only e mutacao legada compatibilizada. +- Registrar que runtime reconfiguration concorrente nao e contrato publico. +- Preservar source/binary compatibility. +- Preparar a Entrega 02 para encapsular estado sem assumir que mutabilidade publica pode ser removida imediatamente. +- Proteger o contrato com testes de caracterizacao focados. + +## Non-Goals + +- Eliminar todo estado global. +- Adicionar `Freeze()`, `Seal()`, `CompleteConfiguration()` ou API semelhante. +- Remover ou tornar obsoletos `EntityMaps` e `TypeConventions`. +- Tornar membros publicos apenas para teste. +- Reabilitar paralelismo de testes. +- Transformar FluentMap em container de DI. +- Mudar profiles para usar mutation scope de `SqlMapper.SetTypeMap`. +- Alterar Dommel. + +## Compatibility Constraints + +- `FluentMapper.Initialize(...)` deve manter sua assinatura e comportamento aditivo atual. +- `AddMap(new Map())`, `AddMap()`, `AddProfile()`, conventions, naming policies e scanning permanecem publicos. +- Duplicidades continuam falhando pelas regras ja existentes. +- `EntityMaps` e `TypeConventions` continuam publicos nesta entrega. +- `FluentMapper.Reset(...)` continua interno e voltado a testes. +- `Dapper.Query()` continua usando o type map global default. +- `QueryMapped()` continua selecionando profile por operacao. + +## Proposed Contract + +Consumidores devem configurar FluentMap durante startup, validar a configuracao e iniciar queries somente depois disso: + +```csharp +FluentMapper.Initialize(config => +{ + config.AddMap(); + config.AddProfile(); + config.UseNamingPolicy(NamingPolicy.SnakeCase).ForEntity(); +}); + +FluentMapper.Validate(); +``` + +Depois que queries comecarem, a configuracao deve ser tratada como read-only. + +Quando uma aplicacao precisar alterar configuracao em runtime usando APIs existentes, ela deve serializar externamente essa transicao e garantir que nao ha queries concorrentes usando os tipos afetados. Esse uso existe por compatibilidade, mas nao e o modelo recomendado nem contrato de concorrencia. + +Para SQL shapes alternativos da mesma entidade, o caminho suportado e profile query-scoped por `QueryMapped()`, nao troca temporaria de `SqlMapper.SetTypeMap`. + +## Alternatives Considered + +### A - Documentation Contract Only + +Aceita para esta entrega. + +Motivos: + +- menor risco de quebra; +- condiz com o historico de API publica mutavel; +- permite formalizar o contrato antes de encapsular estado; +- suficiente para preparar a Entrega 02. + +### B - Soft Enforcement + +Adiada. + +Possibilidades futuras: + +- diagnostics adicionais; +- API preferencial que exponha views read-only; +- avisos de documentacao XML; +- mecanismos internos de snapshot sem quebrar a fachada publica. + +Motivo para adiar: ainda nao ha modelo de detecao confiavel de "primeira query" sem acoplar o core aos detalhes de uso do Dapper e `QueryMapped*`. + +### C - Runtime Enforcement + +Rejeitada nesta entrega. + +Motivos: + +- exigiria saber quando a aplicacao entrou na fase operacional; +- quebraria chamadas repetidas de `Initialize(...)` que hoje funcionam para configuracao aditiva; +- conflitaria com dicionarios publicos mutaveis preservados por compatibilidade; +- exigiria estrategia de versao/migracao para consumidores. + +## Acceptance Criteria + +- A estrutura `docs/sdd/etapa-6/` existe. +- O README da etapa lista as quatro entregas e status. +- O estado atual de APIs mutadoras, static/global state, `SetTypeMap`, cache, profiles, reset, paralelismo e ausencia de freeze esta documentado. +- A decisao de enforcement esta registrada em `decisions.md`. +- O README publico documenta o lifecycle de configuracao. +- Testes caracterizam `Initialize(...)` repetido aditivo. +- Testes caracterizam mutacao runtime compatibilizada sob acesso serializado. +- Testes caracterizam que mutacao direta de dicionario publico nao e caminho deterministico de configuracao porque bypassa instalacao de type map. +- `docs/sdd/fluentmap-risk-assessment.md` foi revisado para FM-RISK-001 sem marcar o risco como resolvido. +- Validacao obrigatoria foi executada: restore, build, tests e pack. +- `handoff.md` contem contexto suficiente para a Entrega 02. + +## Risks / Residual Risks + +- FM-RISK-001 permanece mitigado, nao resolvido: estado global e `SqlMapper.SetTypeMap` continuam existindo. +- FM-RISK-002 permanece aberto: dicionarios publicos mutaveis podem bypassar registry, validacao e cache. +- O contrato depende de disciplina do consumidor durante a fase operacional. +- A suite continua com paralelismo desabilitado. +- Entrega 02 nao deve assumir que os dicionarios publicos podem ser removidos em minor version. + +## Validation Results + +Environment: + +- SDK: `10.0.302` +- test runner detected: VSTest with xUnit v3 +- core target: `netstandard2.0` +- test target: `net10.0` + +Localized validation: + +```text +dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --filter "FullyQualifiedName~ConfigurationLifecycleTests" +``` + +Result: + +- success; +- 3 tests passed. + +Mandatory validation: + +```text +dotnet restore .\Dapper.FluentMap.sln +dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore +dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build +dotnet pack .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release --no-build --output .\artifacts\packages +``` + +Results: + +- restore: success; +- build: success, 0 warnings, 0 errors; +- tests: success, 215 total tests passed: + - core: 184; + - Dommel: 7; + - analyzers: 9; + - generators: 14; + - generated-registration integration: 1; +- pack: `Dapper.FluentMap.2.0.0.nupkg` created successfully. + +Known pack warnings: + +- `NU5125` for legacy `PackageLicenseUrl`; +- NuGet README recommendation. + +These warnings are pre-existing package metadata debt tracked outside this delivery. diff --git a/docs/sdd/etapa-6/README.md b/docs/sdd/etapa-6/README.md new file mode 100644 index 0000000..af198bc --- /dev/null +++ b/docs/sdd/etapa-6/README.md @@ -0,0 +1,40 @@ +# Etapa 6 - Architectural Hardening + +## Objective + +Formalizar contratos arquiteturais que reduzem ambiguidade sobre estado global, lifecycle de configuracao, integracao com Dapper e futuros caminhos de materializacao. + +Esta etapa preserva a compatibilidade publica existente do core `Dapper.FluentMap` e usa Specification-Driven Development para separar contrato, decisao e implementacao. + +## Deliveries + +| Delivery | Title | Status | Notes | +|---|---|---|---| +| 01 | Configuration Lifecycle | COMPLETED | Lifecycle suportado e mutacoes de runtime formalizados. | +| 02 | Mapping State Encapsulation | NEXT | Planejar reducao segura da exposicao mutavel de estado. | +| 03 | Dapper Compatibility Adapters | PENDING | Isolar contratos de compatibilidade com Dapper. | +| 04 | Generated Materializer Spike | PENDING | Investigar materializer gerado para `DbDataReader`. | + +## Delivery List + +01 Configuration Lifecycle -> COMPLETED +02 Mapping State Encapsulation -> NEXT +03 Dapper Compatibility Adapters -> PENDING +04 Generated Materializer Spike -> PENDING + +## Sources Of Truth + +- `docs/sdd/fluentmap-risk-assessment.md` +- `docs/sdd/etapa-1/` +- `docs/sdd/etapa-2/` +- `docs/sdd/etapa-3/` +- `docs/sdd/etapa-4/` +- `docs/sdd/etapa-5/` +- `src/Dapper.FluentMap/FluentMapper.cs` +- `src/Dapper.FluentMap/MappingRegistry.cs` + +## Current Focus + +Delivery 01 defined the supported configuration lifecycle without removing public APIs and without introducing premature runtime sealing. + +Delivery 02 should use this lifecycle contract as the boundary for planning state encapsulation. diff --git a/docs/sdd/etapa-6/decisions.md b/docs/sdd/etapa-6/decisions.md new file mode 100644 index 0000000..e693e69 --- /dev/null +++ b/docs/sdd/etapa-6/decisions.md @@ -0,0 +1,47 @@ +# Decisoes Da Etapa 6 + +Registre aqui apenas decisoes arquiteturais necessarias as proximas entregas. + +## E6-D001 - Configuration Lifecycle Contract + +O lifecycle publico suportado do FluentMap passa a ser descrito em duas fases: + +```text +Configuration Phase + | + v +Operational Phase +``` + +Durante a `Configuration Phase`, consumidores devem registrar maps, profiles, conventions e naming policies, e podem chamar `Validate()` para falhar cedo. Chamadas repetidas de `FluentMapper.Initialize(...)` continuam permitidas para configuracao aditiva, sujeitas as validacoes e regras de duplicidade ja existentes. + +Ao iniciar queries por `Dapper.Query()`, `QueryMapped()` ou APIs equivalentes, a aplicacao entra na `Operational Phase` para os tipos usados. Nessa fase, a configuracao efetiva deve ser tratada como read-only pelo consumidor. + +Mutacoes depois do inicio das queries permanecem possiveis por compatibilidade binaria/fonte, mas so sao suportadas quando o consumidor garante quiescencia externa: sem queries concorrentes, sem leitores/materializers em execucao para os tipos afetados e com entendimento de que `SqlMapper.SetTypeMap` altera estado global do Dapper. O FluentMap nao garante determinismo para reconfiguracao concorrente em runtime. + +## E6-D002 - Documentation Contract Only For Delivery 01 + +Esta entrega escolhe `A. Documentation Contract Only`. + +Justificativa: + +- `FluentMapper.Initialize(...)` historicamente executa mutacoes imediatas sobre uma instancia estatica de `FluentMapConfiguration`. +- `AddMap`, `AddProfile`, conventions e naming policies sao APIs publicas aditivas ou historicas. +- `FluentMapper.EntityMaps` e `FluentMapper.TypeConventions` continuam publicos e mutaveis por compatibilidade. +- `MappingRegistry.Reset(...)` e interno e usado para isolamento de testes, nao como contrato publico de runtime. +- Adicionar `Freeze()`, `Seal()` ou exceptions depois da primeira query quebraria comportamento atualmente possivel sem uma estrategia de migracao. + +A entrega documenta o contrato, adiciona testes de caracterizacao e prepara a Entrega 02 para encapsulamento de estado. Nenhuma API publica foi removida, nenhuma API de freeze foi adicionada e nenhum enforcement de runtime foi introduzido. + +## E6-D003 - Profiles Remain Query-Scoped + +Profiles continuam sendo alternativa query-scoped para SQL shapes diferentes da mesma entidade. + +O contrato preservado e: + +- `Dapper.Query()` usa apenas o default map instalado no type map global do Dapper. +- `QueryMapped()` seleciona o profile por operacao. +- Profiles nao trocam `SqlMapper.SetTypeMap` temporariamente. +- Conventions e naming policies permanecem por entidade e sao lidas por profiles sem mutacao global por query. + +Qualquer entrega futura que tente aplicar profiles ao caminho `Dapper.Query()`, multi-mapping ou Dommel deve tratar isso como nova decisao arquitetural. diff --git a/docs/sdd/etapa-6/handoff.md b/docs/sdd/etapa-6/handoff.md new file mode 100644 index 0000000..58d651a --- /dev/null +++ b/docs/sdd/etapa-6/handoff.md @@ -0,0 +1,81 @@ +# Etapa 6 Handoff + +## Last Completed Delivery + +01 - Configuration Lifecycle + +## Current Architecture + +`FluentMapper` remains a process-wide facade over static configuration state: + +- static `MappingRegistry`; +- static `FluentMapConfiguration`; +- public mutable `EntityMaps`; +- public mutable `TypeConventions`; +- Dapper global type-map integration through `SqlMapper.SetTypeMap`. + +The supported lifecycle is now documented as: + +```text +Configuration Phase + | + v +Operational Phase +``` + +Configuration should happen during startup or before first use of the affected types. Once queries begin, effective configuration should be treated as read-only. Runtime mutation through public APIs remains possible only as a compatibility behavior under external quiescence. + +Profiles remain query-scoped through `QueryMapped()` and do not swap the Dapper global type map. + +## Decisions That Must Be Preserved + +- E6-D001 - Configuration lifecycle is startup configuration followed by read-only operation. +- E6-D002 - Delivery 01 chose Documentation Contract Only; no `Freeze()`, no sealing API and no runtime enforcement. +- E6-D003 - Profiles remain query-scoped and must not be implemented by temporary `SqlMapper.SetTypeMap` mutation. + +## Files Changed + +- `README.md` +- `docs/sdd/fluentmap-risk-assessment.md` +- `docs/sdd/etapa-6/README.md` +- `docs/sdd/etapa-6/decisions.md` +- `docs/sdd/etapa-6/handoff.md` +- `docs/sdd/etapa-6/01-configuration-lifecycle.md` +- `test/Dapper.FluentMap.Tests/ConfigurationLifecycleTests.cs` + +## Public API Impact + +No public API was added, removed, renamed or marked obsolete. + +The public documentation now states: + +- configure during startup; +- optionally call `FluentMapper.Validate()`; +- treat configuration as read-only once queries begin; +- runtime mutation after queries is compatibility-only and requires external quiescence; +- direct dictionary mutation is legacy and can bypass validation, cache invalidation and Dapper type-map installation. + +## Remaining Risks + +- FM-RISK-001 remains mitigated, not resolved: global FluentMap/Dapper state still exists. +- FM-RISK-002 remains open: public mutable dictionaries can still bypass registry validation/cache invalidation. +- Test assemblies still disable parallelization because of global state. +- There is still no immutable snapshot registry. +- There is still no runtime enforcement of the lifecycle boundary. + +## Preconditions for Delivery 02 + +- Read `docs/sdd/etapa-6/01-configuration-lifecycle.md` and `docs/sdd/etapa-6/decisions.md`. +- Preserve source/binary compatibility unless a future major-version plan is explicit. +- Treat public dictionaries as compatibility debt, not as implementation detail that can be removed. +- Use existing tests in `ConfigurationLifecycleTests`, `MappingRegistryTests`, `DiagnosticsApiTests` and `MappingProfileTests` as lifecycle baseline. +- Keep Dommel out of scope unless a core change provably requires review. + +## Things Delivery 02 Must Not Assume + +- Do not assume `Initialize(...)` is currently one-shot. +- Do not assume runtime mutation can be forbidden in a minor-compatible change. +- Do not assume public dictionary mutation triggers registry validation, cache invalidation or `SqlMapper.SetTypeMap`. +- Do not assume profiles are visible to `Dapper.Query()` or Dommel. +- Do not assume test parallelization can be re-enabled before global state is encapsulated or isolated. +- Do not add a freeze/seal API without a compatibility and migration decision. diff --git a/docs/sdd/fluentmap-risk-assessment.md b/docs/sdd/fluentmap-risk-assessment.md new file mode 100644 index 0000000..befe587 --- /dev/null +++ b/docs/sdd/fluentmap-risk-assessment.md @@ -0,0 +1,1017 @@ +# FluentMap - Consolidated Risk Assessment + +## 1. Executive Summary + +This assessment reconstructs the FluentMap SDD history from `docs/sdd/etapa-1` through `docs/sdd/etapa-5`, plus the `.NET 10` migration and the SQLite dependency hardening. It consolidates only risks that still have current evidence as `OPEN`, `MITIGATED`, or `UNKNOWN`. Historical items that were later resolved or superseded are listed separately in section 10. + +Development history reconstructed from repository evidence: + +```text +Etapa 1 + - 01 ReflectionHelper + - 02 Mapping composition + - 03 Dapper integration tests + - 04 MappingRegistry and cache + +Etapa 2 + - 01 MemberPath + - 02 Configuration validation and diagnostics + - 03 Inherited mappings + - 04 Naming policies + +Etapa 3 + - 01 Mapping registration and discovery + - 02 Constructor mapping and immutable types + - 03 Validate and Explain + +Etapa 4 + - 01 Roslyn analyzers + - 02 Trimming and Native AOT + - 03 Source generator + +Etapa 5 + - 01 Nested/value-object materialization spike + - 02 Nested object materialization + - 03 Immutable value objects + - 04 Mapping profiles + +.NET 10 migration + - 01 Inventory and baseline + - 02 Test projects on net10.0 + - 03 Source project dependencies + - 04 Validation, pack and CI + - 05 xUnit 3 migration + +Security hardening + - SQLitePCLRaw vulnerability correction +``` + +Current risk count: + +- Total current items: 18 +- Critical: 0 +- High: 3 +- Medium: 10 +- Low: 5 +- Open: 9 +- Mitigated: 7 +- Unknown: 2 + +Overall, FluentMap is not in a critical architectural state. The main runtime contract is well protected by SDD decisions, integration tests, fail-fast validation, `MemberPath`, cache keys, and query-scoped profiles. The largest remaining risks come from intentionally preserved global/static compatibility surfaces, trimming/AOT constraints, and the fact that the new materializer is runtime/reflection-based rather than generated. + +## 2. Risk Distribution + +| Severity | Count | +| -------- | ----: | +| Critical | 0 | +| High | 3 | +| Medium | 10 | +| Low | 5 | + +## 3. Priority Matrix + +| ID | Problem | Severity | Probability | Area | Origin | Status | +| -- | ------- | -------- | ----------- | ---- | ------ | ------ | +| FM-RISK-001 | Global FluentMap/Dapper mapping state constrains thread safety and runtime reconfiguration | HIGH | Medium | Concurrency, Thread Safety | Etapa 1 / Entrega 03-04 | MITIGATED | +| FM-RISK-002 | Public mutable dictionaries can bypass registry validation and cache invalidation | HIGH | Medium | Architecture, Compatibility | Etapa 1 / Entrega 04 | OPEN | +| FM-RISK-003 | Assembly scanning can fail under trimming/AOT and produced a failing trimmed smoke | HIGH | Medium | Reflection, Compatibility | Etapa 3 / Entrega 01; Etapa 4 / Entrega 02 | MITIGATED | +| FM-RISK-004 | `QueryMapped*` remains runtime/reflection/dynamic-code based; no generated materializer exists | MEDIUM | Medium | AOT, Performance | Etapa 5 / Entrega 02-04 | MITIGATED | +| FM-RISK-005 | `QueryMapped*` buffers all rows and has no streaming/unbuffered mode | MEDIUM | Medium | Performance, Memory | Etapa 5 / Entrega 04 | OPEN | +| FM-RISK-006 | Value Object support excludes factories, private constructors/setters, fields and NRT semantics | MEDIUM | Medium | Value Objects, API Design | Etapa 5 / Entrega 03 | OPEN | +| FM-RISK-007 | Dapper TypeHandler integration in the runtime materializer depends on reflective access to `SqlMapper.TypeHandlerCache` | MEDIUM | Low | Compatibility, Reflection | Etapa 5 / Entrega 03 | MITIGATED | +| FM-RISK-008 | Mapping profiles do not support per-profile conventions/naming policies | MEDIUM | Medium | Profiles, Extensibility | Etapa 5 / Entrega 04 | OPEN | +| FM-RISK-009 | Mapping profiles do not apply to `Dapper.Query` or Dapper multi-mapping | MEDIUM | Medium | Profiles, API Design | Etapa 5 / Entrega 04 | OPEN | +| FM-RISK-010 | Legacy `ApplyMapsFromAssemblies` keeps older reflection/discovery behavior | MEDIUM | Low | Reflection, Maintainability | Etapa 2 / Entrega 02; Etapa 3 / Entrega 01 | MITIGATED | +| FM-RISK-011 | Constructor overload ambiguity and optional parameters remain delegated to Dapper | MEDIUM | Low | Materialization, Correctness | Etapa 3 / Entrega 02 | OPEN | +| FM-RISK-012 | `IgnoredPropertyInfo` sentinel throws `NotImplementedException` if inspected outside the intended path | MEDIUM | Low | Correctness, Maintainability | Etapa 2 / Entrega 02 | MITIGATED | +| FM-RISK-013 | Dommel behavior for profiles/nested materialization is intentionally unreviewed | MEDIUM | Low | Dommel, Extensibility | Etapa 5 / Entrega 04 | UNKNOWN | +| FM-RISK-014 | Analyzer and generator coverage is intentionally partial | LOW | High | Developer Experience, Testing | Etapa 4 / Entrega 01-03 | MITIGATED | +| FM-RISK-015 | Async `QueryMapped*` overloads are asymmetric: profile async exists, default async does not | LOW | Medium | API Design | Etapa 5 / Entrega 04 | OPEN | +| FM-RISK-016 | NuGet package metadata remains legacy (`PackageLicenseUrl`, no package README/SourceLink metadata) | LOW | High | Documentation, Developer Experience, Packaging | .NET 10 / Entrega 04; Security hardening | OPEN | +| FM-RISK-017 | Remote CI execution remains unproven after CI modernization | LOW | Medium | Testing, Maintainability | .NET 10 / Entrega 04-05 | UNKNOWN | +| FM-RISK-018 | Documentation carries archived/legacy signals alongside new SDD features | LOW | Medium | Documentation | README and SDD summaries | OPEN | + +## 4. Critical Risks + +No critical risks were identified from the available evidence. + +## 5. High Risks + +## FM-RISK-001 - Global FluentMap/Dapper mapping state constrains thread safety and runtime reconfiguration + +**Severidade:** HIGH +**Status:** MITIGATED +**Categoria:** Architecture, Concurrency, Thread Safety, Mapping, Compatibility +**Origem:** Etapa 1 / Entregas 03 and 04 +**Detectado em:** planning, implementation and test review +**Componentes afetados:** `FluentMapper`, `MappingRegistry`, `SqlMapper.SetTypeMap`, tests using global reset + +### Descricao + +FluentMap still relies on process-wide mapping state and Dapper's global type-map registry. The code now uses `ConcurrentDictionary`, structured cache keys and invalidation, but the architecture remains global. Runtime reconfiguration while queries are executing is not proven safe as a public contract. + +### Evidencias + +- `docs/sdd/etapa-1/03-dapper-integration-tests.md`: identifies static `EntityMaps`, static `TypeConventions`, static `_configuration`, `SqlMapper.SetTypeMap`, cache interference and disabled parallelism. +- `docs/sdd/etapa-1/04-mapping-registry-cache.md`: resolves cache key and reset issues but explicitly keeps public mutable dictionaries, Dapper global type maps and disabled parallelism. +- `docs/sdd/net10-migration/05-xunit3-migration.md`: preserves `[assembly: CollectionBehavior(DisableTestParallelization = true)]` because tests use global FluentMapper/Dapper/Dommel state. +- `src/Dapper.FluentMap/FluentMapper.cs`: static `_registry`, static `_configuration`, public static `EntityMaps` and `TypeConventions`. +- `src/Dapper.FluentMap/MappingRegistry.cs`: `SetDapperTypeMap` calls `SqlMapper.SetTypeMap(type, instance)`. +- `test/Dapper.FluentMap.Tests/ManualMappingTests.cs` and `test/Dapper.FluentMap.Dommel.Tests/ManualMappingTests.cs`: assembly-level test parallelization disabled. +- `docs/sdd/etapa-6/01-configuration-lifecycle.md`: defines the supported lifecycle as startup configuration followed by read-only operation, with runtime mutation allowed only under external quiescence for compatibility. +- `test/Dapper.FluentMap.Tests/ConfigurationLifecycleTests.cs`: characterizes repeated additive `Initialize`, serialized runtime registration compatibility, and direct dictionary mutation bypassing Dapper type-map installation. + +### Cenario de impacto + +An application dynamically reinitializes mappings for the same entity while requests are still materializing rows. One request can observe old mappings, another can observe new mappings, and Dapper's global type-map registry can be replaced mid-flight. + +### Impacto + +Potential non-deterministic mapping behavior, hard-to-reproduce test failures, and incorrect materialization if consumers treat `Initialize` as a runtime mutation API instead of startup configuration. + +### Probabilidade + +Media. The normal startup-once usage is safe enough, but the public static shape makes runtime mutation possible and tests remain serialized because of it. + +### Workaround atual + +Configure FluentMap once during application startup. Avoid mutating mappings after queries begin. Use query-scoped profiles for alternate shapes instead of replacing Dapper type maps. + +### Recomendacao + +Use the Etapa 6 lifecycle contract as the public boundary: configure during startup, validate, and treat the effective configuration as read-only during operation. Investigate an immutable snapshot registry and reduced public mutability for future versions. Any stronger runtime enforcement must preserve source/binary compatibility or be planned as a major version. + +### Relacoes + +Related to FM-RISK-002, FM-RISK-005, FM-RISK-013 and the Etapa 5 research item "cache imutavel/snapshot". + +## FM-RISK-002 - Public mutable dictionaries can bypass registry validation and cache invalidation + +**Severidade:** HIGH +**Status:** OPEN +**Categoria:** Architecture, Correctness, API Design, Compatibility, Technical Debt +**Origem:** Etapa 1 / Entrega 04 +**Detectado em:** implementation decision and compatibility review +**Componentes afetados:** `FluentMapper.EntityMaps`, `FluentMapper.TypeConventions`, `MappingRegistry` + +### Descricao + +`FluentMapper.EntityMaps` and `FluentMapper.TypeConventions` remain public mutable dictionaries for compatibility. Consumers can mutate them directly, bypassing `MappingRegistry.AddEntityMap`, `AddConvention`, validation, cache invalidation and Dapper type-map installation. + +### Evidencias + +- `docs/sdd/etapa-1/04-mapping-registry-cache.md`: explicitly lists direct public mutation as deliberately unresolved. +- `docs/sdd/etapa-1/decisions.md`: keeps public dictionaries and says reducing their mutability is a compatibility-planned change. +- `docs/sdd/etapa-3/03-diagnostics-api.md`: keeps the dictionaries public for compatibility. +- `src/Dapper.FluentMap/FluentMapper.cs`: exposes `public static readonly ConcurrentDictionary EntityMaps` and `public static readonly ConcurrentDictionary> TypeConventions`. +- `src/Dapper.FluentMap/MappingRegistry.cs`: validation and invalidation happen only through registry methods, not through arbitrary dictionary mutation. + +### Cenario de impacto + +A consumer directly assigns `FluentMapper.EntityMaps[typeof(Customer)] = new CustomerMap()` after a miss for a column was cached. The registry may not invalidate the existing cache entry or reinstall the Dapper type map for that type. + +### Impacto + +Mappings can be silently ignored or stale. Diagnostics may disagree with materialization, and failures can be hard to attribute to direct dictionary mutation. + +### Probabilidade + +Media. Direct dictionary access is public and historically available, but most documented examples use `Initialize`. + +### Workaround atual + +Use `FluentMapper.Initialize`, `AddMap`, `AddMap`, `AddProfile` and convention APIs only. Do not mutate `EntityMaps` or `TypeConventions` directly. + +### Recomendacao + +Document direct mutation as legacy compatibility surface and introduce read-only public views plus explicit migration guidance in a future major version. Consider internal detection of dictionary replacement/mutation only if it can be done without breaking consumers. + +### Relacoes + +Related to FM-RISK-001 and FM-RISK-013. This is the main blocker for re-enabling test parallelism safely. + +## FM-RISK-003 - Assembly scanning can fail under trimming/AOT and produced a failing trimmed smoke + +**Severidade:** HIGH +**Status:** MITIGATED +**Categoria:** Reflection, Compatibility, Architecture, Developer Experience +**Origem:** Etapa 3 / Entrega 01; Etapa 4 / Entrega 02 +**Detectado em:** planning, implementation and trimming smoke validation +**Componentes afetados:** `AddMapsFromAssembly`, `AddMapsFromAssemblyContaining`, `ForEntitiesInAssembly`, `ForEntitiesInCurrentAssembly`, `ApplyMapsFromAssemblies` + +### Descricao + +Assembly scanning remains supported for normal runtime usage, but it is reflection-dependent and trimming-sensitive. The trimmed scanning smoke published successfully with expected warnings and then failed at runtime because mapping metadata was removed. + +### Evidencias + +- `docs/sdd/etapa-3/01-mapping-registration.md`: documents scanning via reflection and `Activator.CreateInstance` as remaining AOT/trimming debt. +- `docs/sdd/etapa-4/02-trimming-aot.md`: classifies scanning APIs as reflection-dependent, marks them with `RequiresUnreferencedCode`, and records a trimmed scanning runtime failure. +- `docs/sdd/etapa-4/03-source-generator.md`: positions generated registration as the alternative to scanning, but not as a full materializer. +- `README.md`: tells trimmed/AOT consumers to prefer explicit registration and documents scanning as trimming-sensitive. +- `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs`: scanning uses `Assembly.GetExportedTypes()` and `Activator.CreateInstance(mapType)`. +- `src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs`: convention scanning uses `GetExportedTypes()`. +- `src/Dapper.FluentMap/Utils/FluentMapConfigurationExtensions.cs`: legacy scanning uses `GetTypes()`, `MakeGenericMethod` and `Activator.CreateInstance`. + +### Cenario de impacto + +A Native AOT or trimmed application calls `AddMapsFromAssemblyContaining()`. The trimmer removes a map or interface metadata that scanning needs. The app starts with incomplete mappings, and a later query falls back to Dapper defaults or fails. + +### Impacto + +Potential missing mappings, incorrect column/property association, or startup/runtime failures in trimmed applications. + +### Probabilidade + +Media. The problem is proven in the repository smoke, but only affects consumers using scanning under trimming/AOT or ignoring analyzer/publish warnings. + +### Workaround atual + +Use `AddMap()` or the source generator `AddGeneratedMappings()` for trimmed/AOT applications. Avoid assembly scanning in publish modes that remove metadata. + +### Recomendacao + +Keep scanning as documented convenience only. Make README examples more explicit about scanning not being an AOT-friendly path, and consider analyzer guidance that flags scanning in projects with trimming/AOT properties when static evidence is reliable. + +### Relacoes + +Related to FM-RISK-004, FM-RISK-010 and Etapa 4 decisions about runtime remaining authoritative. + +## 6. Medium Risks + +## FM-RISK-004 - `QueryMapped*` remains runtime/reflection/dynamic-code based; no generated materializer exists + +**Severidade:** MEDIUM +**Status:** MITIGATED +**Categoria:** AOT, Trimming, Reflection, Performance, Materialization +**Origem:** Etapa 5 / Entregas 02, 03 and 04 +**Detectado em:** architecture decision, implementation and validation +**Componentes afetados:** `QueryMappedExtensions`, `NestedMaterializationPlan`, `Dapper.FluentMap.Generators` + +### Descricao + +Nested materialization, Value Object construction and profiles are implemented through `QueryMapped*`, which reads a data reader and builds cached runtime plans using reflection and expression compilation. This path is annotated with `RequiresUnreferencedCode` and `RequiresDynamicCode`; the generator still only generates registration, not a `DbDataReader` materializer. + +### Evidencias + +- `docs/sdd/etapa-5/02-nested-object-materialization.md`: documents runtime reflection, expression compilation, plan cache and AOT/trimming annotations. +- `docs/sdd/etapa-5/03-value-objects.md`: states `QueryMapped*` remains annotated and that generated materializer remains future work. +- `docs/sdd/etapa-5/04-mapping-profiles.md`: says generated query/materializer is deferred. +- `docs/sdd/etapa-5/README.md`: P1 item to create a generated `DbDataReader` materializer. +- `src/Dapper.FluentMap/QueryMappedExtensions.cs`: all public `QueryMapped*` methods are annotated with `RequiresUnreferencedCode` and `RequiresDynamicCode`. +- `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs`: compiles delegates for constructors, getters, setters and converters. + +### Cenario de impacto + +A consumer wants nested immutable Value Objects in a Native AOT application. The only implemented path is `QueryMapped*`, which requires runtime code generation and reflection metadata that AOT/trimming may reject or remove. + +### Impacto + +Limits production use in Native AOT/trimming-heavy applications and leaves performance below the potential of generated row materializers. + +### Probabilidade + +Media. This affects a narrower but increasingly important deployment style. The APIs are annotated, reducing surprise. + +### Workaround atual + +Use explicit/generated registration for startup mapping and avoid `QueryMapped*` in Native AOT until a generated materializer exists. Use Dapper's normal `Query` for simple root mappings. + +### Recomendacao + +Prioritize a generated materializer for `DbDataReader` that covers nested paths, Value Objects and profiles without expression compilation in the hot path. Keep runtime `QueryMapped*` as fallback for dynamic configurations. + +### Relacoes + +Related to FM-RISK-003, FM-RISK-005, FM-RISK-006 and FM-RISK-007. + +## FM-RISK-005 - `QueryMapped*` buffers all rows and has no streaming/unbuffered mode + +**Severidade:** MEDIUM +**Status:** OPEN +**Categoria:** Performance, Memory, Materialization, API Design +**Origem:** Etapa 5 / Entrega 04 +**Detectado em:** implementation and roadmap +**Componentes afetados:** `QueryMappedExtensions.Materialize` + +### Descricao + +`QueryMapped*` reads the entire data reader into a `List` before returning. There is no unbuffered streaming equivalent, which can increase memory pressure for large result sets. + +### Evidencias + +- `docs/sdd/etapa-5/README.md`: lists no streaming/unbuffered support as a main limitation and a P1/P2 follow-up. +- `docs/sdd/etapa-5/04-mapping-profiles.md`: states `QueryMapped*` returns a materialized list and streaming was not implemented. +- `src/Dapper.FluentMap/QueryMappedExtensions.cs`: `Materialize` creates `var results = new List();` and returns it after the reader loop. + +### Cenario de impacto + +A reporting query returns hundreds of thousands of rows with nested Value Objects. `QueryMapped()` stores all rows before the caller can start processing, causing high memory usage. + +### Impacto + +Memory growth, slower first-row availability, and inability to mirror Dapper's unbuffered query behavior for supported nested mappings. + +### Probabilidade + +Media. Large result sets are common, but nested/value-object mapping is opt-in and many use cases will be small projections. + +### Workaround atual + +Use Dapper `Query` for simple mappings, page large result sets manually, or write a custom `DbDataReader` loop for heavy streaming scenarios. + +### Recomendacao + +Design streaming overloads with explicit connection/reader lifetime semantics. Do not expose lazy enumeration over a disposed reader; the API must define ownership clearly. + +### Relacoes + +Related to FM-RISK-004 and the Etapa 5 P1 item for streaming/unbuffered support. + +## FM-RISK-006 - Value Object support excludes factories, private constructors/setters, fields and NRT semantics + +**Severidade:** MEDIUM +**Status:** OPEN +**Categoria:** Value Objects, Materialization, API Design, Extensibility +**Origem:** Etapa 5 / Entrega 03 +**Detectado em:** architecture decision and implementation +**Componentes afetados:** `NestedMaterializationPlan`, `MappingConfigurationValidator`, `QueryMappedExtensions` + +### Descricao + +The current Value Object contract supports public constructors whose parameters can be bound from mapped properties or nested objects. Factory methods, private constructors, private setters, field/backing-field injection, `FormatterServices`, and nullable reference type metadata are intentionally outside the contract. + +### Evidencias + +- `docs/sdd/etapa-5/03-value-objects.md`: explicitly lists factory methods, private constructor/setter, field injection and NRT metadata as out of scope. +- `docs/sdd/etapa-5/decisions.md`: states factory methods require an explicit future API and no private constructor/private setter/field injection support exists. +- `README.md`: says factory methods and generated materializers are not part of the runtime path. +- `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs`: uses public constructors and public setter delegates. +- `test/Dapper.FluentMap.Tests/ValueObjectMaterializationTests.cs`: covers public constructor support and rejection of incomplete/ambiguous constructor scenarios. + +### Cenario de impacto + +A domain model exposes `Cpf.Create(string)` and keeps constructors private to enforce invariants. `QueryMapped*` cannot construct it from `Cpf.Number`, even though the model is common in DDD-style codebases. + +### Impacto + +Consumers must change model visibility, map the whole Value Object through a Dapper `TypeHandler`, or avoid FluentMap-controlled materialization for that shape. + +### Probabilidade + +Media. Public-constructor Value Objects are supported, but private factories are common enough in domain models. + +### Workaround atual + +Use a public constructor, use a Dapper `TypeHandler` when the whole Value Object maps to one column, or materialize manually. + +### Recomendacao + +Design a strongly typed factory API with deterministic ambiguity rules and validation. Do not infer factories by name or reflection convention. + +### Relacoes + +Related to FM-RISK-004, FM-RISK-007 and Etapa 5 P2 factory-method follow-up. + +## FM-RISK-007 - Dapper TypeHandler integration in the runtime materializer depends on reflective access to `SqlMapper.TypeHandlerCache` + +**Severidade:** MEDIUM +**Status:** MITIGATED +**Categoria:** Compatibility, Reflection, Value Objects, Maintainability +**Origem:** Etapa 5 / Entrega 03 +**Detectado em:** implementation review +**Componentes afetados:** `NestedMaterializationPlan.CreateTypeHandlerConverter` + +### Descricao + +The runtime materializer detects a Dapper type handler with `SqlMapper.HasTypeHandler`, but then calls Dapper's nested `TypeHandlerCache.Parse` using reflection. This couples the implementation to a Dapper type/cache shape that may change across Dapper versions. + +### Evidencias + +- `docs/sdd/etapa-5/01-nested-materialization-spike.md`: records that conversions should respect TypeHandlers without copying Dapper internals. +- `docs/sdd/etapa-5/03-value-objects.md`: states TypeHandler support is preserved for scalar Value Object properties. +- `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs`: `CreateTypeHandlerConverter` uses `typeof(SqlMapper).GetNestedType("TypeHandlerCache`1", BindingFlags.Public | BindingFlags.NonPublic)`, `MakeGenericType` and reflection to call `Parse`. +- `test/Dapper.FluentMap.Tests/ValueObjectMaterializationTests.cs`: verifies `QueryMappedShouldUseDapperTypeHandlerForScalarValueObjectProperty`. +- `src/Dapper.FluentMap/Dapper.FluentMap.csproj`: Dapper is pinned to `2.1.79`, reducing immediate drift. + +### Cenario de impacto + +A future Dapper update removes, renames or changes `TypeHandlerCache.Parse`. FluentMap still sees `HasTypeHandler == true`, but cannot call the handler through this reflective path and falls back to conversion behavior that may not support the Value Object. + +### Impacto + +Potential regression in scalar Value Object handling under `QueryMapped*` after Dapper upgrades. + +### Probabilidade + +Baixa. The current dependency is pinned and covered by tests, but the risk rises during dependency updates. + +### Workaround atual + +Keep Dapper upgrade tasks isolated and run `ValueObjectMaterializationTests`. Consumers can use Dapper `Query` for scalar TypeHandler paths outside `QueryMapped*`. + +### Recomendacao + +Investigate a public Dapper-supported handler invocation path. If none exists, wrap this behavior behind a small compatibility adapter with targeted tests and explicit Dapper-version review notes. + +### Relacoes + +Related to FM-RISK-004 and any future Dapper dependency update. + +## FM-RISK-008 - Mapping profiles do not support per-profile conventions/naming policies + +**Severidade:** MEDIUM +**Status:** OPEN +**Categoria:** Profiles, Extensibility, API Design, Mapping +**Origem:** Etapa 5 / Entrega 04 +**Detectado em:** architecture decision and roadmap +**Componentes afetados:** `MappingRegistry.ProfileMaps`, `TypeConventions`, profile query path + +### Descricao + +Profiles are query-scoped and can define explicit maps, but conventions and naming policies are still registered by entity. The profile path applies entity-level conventions read-only; it cannot define conventions scoped only to one profile. + +### Evidencias + +- `docs/sdd/etapa-5/04-mapping-profiles.md`: explicitly defers per-profile conventions/naming policies. +- `docs/sdd/etapa-5/decisions.md`: says conventions/naming policies continue by entity and per-profile conventions are future debt. +- `docs/sdd/etapa-5/README.md`: P1 item to define per-profile conventions/naming policies before expanding policy composition. +- `src/Dapper.FluentMap/MappingRegistry.cs`: stores default/profile maps separately, but conventions remain `ConcurrentDictionary> TypeConventions`. +- `test/Dapper.FluentMap.Tests/MappingProfileTests.cs`: validates entity naming policy applied to a profile, not per-profile policy registration. + +### Cenario de impacto + +The same `Customer` entity has one legacy profile using `legacy_customer_id` and a reporting profile using `report_customer_id`. The consumer wants a prefix policy per profile but must map each property explicitly. + +### Impacto + +More boilerplate and higher maintenance cost for profiles with broad naming differences. + +### Probabilidade + +Media. Profiles exist exactly to support different SQL shapes, and naming conventions often vary between systems. + +### Workaround atual + +Use explicit mappings inside each profile map. + +### Recomendacao + +Design profile-scoped convention storage and precedence rules before adding APIs. Ensure defaults do not leak into profiles silently except where explicitly documented. + +### Relacoes + +Related to FM-RISK-009 and the profile decisions in Etapa 5. + +## FM-RISK-009 - Mapping profiles do not apply to `Dapper.Query` or Dapper multi-mapping + +**Severidade:** MEDIUM +**Status:** OPEN +**Categoria:** Profiles, Materialization, API Design, Compatibility +**Origem:** Etapa 5 / Entrega 04 +**Detectado em:** architecture decision +**Componentes afetados:** `QueryMappedExtensions`, `MappingRegistry`, Dapper integration + +### Descricao + +Profiles are intentionally available only through `QueryMapped()` and related opt-in APIs. `Dapper.Query()` continues to use the default mapping, and Dapper multi-mapping has no profile overload. + +### Evidencias + +- `docs/sdd/etapa-5/04-mapping-profiles.md`: rejects mutation-scope profiles through `SqlMapper.SetTypeMap` and states profiles do not apply to `Dapper.Query` or multi-mapping. +- `docs/sdd/etapa-5/decisions.md`: says multiple profiles per type are supported only in `QueryMapped*`. +- `README.md`: documents that `Dapper.Query` and `QueryMapped` use default mapping; profile selection is tied to `QueryMapped()`. +- `test/Dapper.FluentMap.Tests/MappingProfileTests.cs`: verifies `DapperQueryShouldContinueUsingDefaultMapping`. + +### Cenario de impacto + +A consumer uses Dapper multi-mapping to compose aggregates and wants the second object to use a profile. FluentMap has no query-scoped hook for Dapper's multi-mapping API. + +### Impacto + +Profiles cannot cover some common Dapper query patterns. Consumers must choose between custom callbacks/manual mapping and `QueryMapped*` limitations. + +### Probabilidade + +Media. Dapper multi-mapping is a common advanced feature, but profile support is explicitly opt-in and new. + +### Workaround atual + +Use `QueryMapped()` for single-entity materialization or manual Dapper multi-mapping callbacks for multi-entity composition. + +### Recomendacao + +Track demand before expanding the API. If implemented, avoid temporary `SqlMapper.SetTypeMap` swaps; use an operation-scoped materializer or wait for a public Dapper hook. + +### Relacoes + +Related to FM-RISK-001, FM-RISK-008 and Etapa 5 rejected alternative "Mutation scope". + +## FM-RISK-010 - Legacy `ApplyMapsFromAssemblies` keeps older reflection/discovery behavior + +**Severidade:** MEDIUM +**Status:** MITIGATED +**Categoria:** Reflection, Compatibility, Maintainability, Developer Experience +**Origem:** Etapa 2 / Entrega 02; Etapa 3 / Entrega 01 +**Detectado em:** discovery/reflection review +**Componentes afetados:** `FluentMapConfigurationExtensions.ApplyMapsFromAssemblies` + +### Descricao + +The modern scanning APIs are deterministic and integrated into the registry, but the legacy `ApplyMapsFromAssemblies` remains for compatibility. It still uses `Assembly.GetTypes()`, reflection invocation and `Activator.CreateInstance`, and earlier SDD notes left its discovery diagnostics outside functional redesign. + +### Evidencias + +- `docs/sdd/etapa-2/02-configuration-validation.md`: keeps `FluentMapConfigurationExtensions.ApplyMapsFromAssemblies` discovery/reflection diagnostics outside scope. +- `docs/sdd/etapa-3/01-mapping-registration.md`: preserves `ApplyMapsFromAssemblies(...)` for compatibility and adds modern alternatives. +- `docs/sdd/etapa-4/02-trimming-aot.md`: marks the legacy API as trimming-sensitive. +- `src/Dapper.FluentMap/Utils/FluentMapConfigurationExtensions.cs`: uses `GetTypes()`, `MakeGenericMethod`, `Invoke`, `Activator.CreateInstance`, and throws `InvalidOperationException` for duplicate mappings. + +### Cenario de impacto + +A legacy consumer scans assemblies with maps that include base maps or contain problematic types. The legacy path can produce reflection-shaped errors and lacks the same documented deterministic preflight behavior as the modern API. + +### Impacto + +Diagnostics and ordering may be less predictable than modern registration APIs, and trimming/AOT behavior is fragile. + +### Probabilidade + +Baixa. Modern APIs and generator are documented, but legacy consumers can still call this public extension. + +### Workaround atual + +Use `AddMap()`, `AddMapsFromAssembly(...)`, `AddMapsFromAssemblyContaining()`, or `AddGeneratedMappings()`. + +### Recomendacao + +Document `ApplyMapsFromAssemblies` as legacy. In a future major version, consider deprecation or routing it through the same modern scanning implementation if behavior can be preserved. + +### Relacoes + +Related to FM-RISK-003 and Etapa 3 registration decisions. + +## FM-RISK-011 - Constructor overload ambiguity and optional parameters remain delegated to Dapper + +**Severidade:** MEDIUM +**Status:** OPEN +**Categoria:** Materialization, Correctness, Compatibility +**Origem:** Etapa 3 / Entrega 02 +**Detectado em:** architecture decision and tests +**Componentes afetados:** `FluentConstructorTypeMap`, `MultiTypeMap`, Dapper `DefaultTypeMap` + +### Descricao + +FluentMap translates configured column metadata to Dapper for simple constructor mapping, but it does not own root constructor selection ambiguity, optional parameter behavior or Dapper's underscore matching flag. These remain governed by Dapper. + +### Evidencias + +- `docs/sdd/etapa-3/02-constructor-immutable-mapping.md`: states constructor overload ambiguity remains Dapper responsibility and optional parameters receive no special handling. +- `docs/sdd/etapa-3/decisions.md`: says constructor selection remains delegated to `DefaultTypeMap`. +- `src/Dapper.FluentMap/TypeMaps/FluentConstructorTypeMap.cs`: delegates constructor matching to Dapper `DefaultTypeMap` after translating names/types. +- `test/Dapper.FluentMap.Tests/ConstructorMappingTests.cs`: covers supported constructor scenarios, including that nested `MemberPath` is not root constructor mapping. + +### Cenario de impacto + +A model has multiple public constructors that Dapper can interpret similarly after FluentMap translates column names. Dapper chooses according to its own rules, or fails, and FluentMap does not add a separate diagnostic layer for that root constructor ambiguity. + +### Impacto + +Behavior can surprise consumers who expect FluentMap's diagnostics to cover all immutable-constructor edge cases. + +### Probabilidade + +Baixa. Common single-constructor and record cases are tested, and ambiguous public constructors are less common. + +### Workaround atual + +Keep materialized entities constructor shapes simple, avoid ambiguous overloads, and use `QueryMapped*` for nested immutable graphs where FluentMap has its own constructor plan. + +### Recomendacao + +Do not reimplement Dapper constructor selection casually. If demand appears, add narrow diagnostics that explain Dapper-delegated ambiguity without changing behavior. + +### Relacoes + +Related to FM-RISK-004 and FM-RISK-006. + +## FM-RISK-012 - `IgnoredPropertyInfo` sentinel throws `NotImplementedException` if inspected outside the intended path + +**Severidade:** MEDIUM +**Status:** MITIGATED +**Categoria:** Correctness, Maintainability, Technical Debt +**Origem:** Etapa 2 / Entrega 02 +**Detectado em:** implementation review +**Componentes afetados:** `IgnoredPropertyInfo`, `MultiTypeMap`, `MappingRegistry.MappingCacheEntry` + +### Descricao + +Ignored and nested mappings use an internal `IgnoredPropertyInfo` sentinel to prevent Dapper fallback. The sentinel overrides many `PropertyInfo` members by throwing `NotImplementedException`. The current `MultiTypeMap` recognizes the sentinel and returns `null`, but misuse or a Dapper behavior change could inspect it. + +### Evidencias + +- `docs/sdd/etapa-2/02-configuration-validation.md`: catalogs `IgnoredPropertyInfo` throwing `NotImplementedException` as partially detectable and outside the delivery scope. +- `src/Dapper.FluentMap/TypeMaps/IgnoredPropertyInfo.cs`: most members throw `NotImplementedException`. +- `src/Dapper.FluentMap/MappingRegistry.cs`: `MappingCacheEntry` assigns `IgnoredPropertyInfo` for ignored maps and nested maps when returning a `PropertyInfo` to Dapper's simple type-map pipeline. +- `src/Dapper.FluentMap/TypeMaps/MultiTypeMap.cs`: explicitly checks `result is IgnoredPropertyInfo || result.Property is IgnoredPropertyInfo` and returns `null`. + +### Cenario de impacto + +A future Dapper version inspects more of the returned `PropertyInfo` before FluentMap can intercept it, or an external mapper uses the sentinel unexpectedly. A `NotImplementedException` escapes from a materialization path. + +### Impacto + +Unexpected runtime failure in ignored/nested mapping paths. + +### Probabilidade + +Baixa. Current tests exercise ignored behavior, and the sentinel is internal. The risk is mostly future compatibility and maintainability. + +### Workaround atual + +None for consumers except staying on tested Dapper versions and using documented APIs. + +### Recomendacao + +Replace the sentinel with an explicit `IMemberMap` or strategy result that never exposes a throwing `PropertyInfo`, if this can be done without breaking Dapper behavior. + +### Relacoes + +Related to FM-RISK-007 and future Dapper compatibility work. + +## FM-RISK-013 - Dommel behavior for profiles/nested materialization is intentionally unreviewed + +**Severidade:** MEDIUM +**Status:** UNKNOWN +**Categoria:** Extensibility, Profiles, Mapping, Documentation +**Origem:** Etapa 5 / Entrega 04 +**Detectado em:** roadmap/research item +**Componentes afetados:** `Dapper.FluentMap.Dommel`, default/profile mapping registry + +### Descricao + +Etapa 5 intentionally did not change Dommel. Profiles and nested materialization are core/query-wrapper features, while Dommel still consumes the historical mapping surfaces. The SDD explicitly asks for a future Dommel review to decide whether profiles should be visible to external CRUD integrations. + +### Evidencias + +- `docs/sdd/etapa-5/README.md`: says Dommel received no functional changes and lists "Revisar Dommel em etapa propria" under research. +- `docs/sdd/etapa-5/decisions.md`: profiles affect `QueryMapped*`; Dapper global type map represents only default mapping. +- `src/Dapper.FluentMap.Dommel/Resolvers/*`: Dommel resolver implementation remains separate. +- `test/Dapper.FluentMap.Dommel.Tests/ManualMappingTests.cs`: Dommel tests cover legacy resolver behavior only. + +### Cenario de impacto + +A consumer expects a Dommel CRUD operation to use a profile map registered by `AddProfile()`. Current evidence indicates profiles are query-scoped to `QueryMapped*`, but Dommel-specific behavior has not been formally reviewed for this new model. + +### Impacto + +Potential documentation/support confusion and extension limitations for Dommel consumers. + +### Probabilidade + +Baixa. Dommel profile integration is not documented as supported, but profile adoption can create expectations. + +### Workaround atual + +Use default maps for Dommel and reserve profiles for `QueryMapped()`. + +### Recomendacao + +Run a dedicated Dommel design/review stage. Decide whether profiles should remain invisible to Dommel or receive explicit APIs, and document the outcome. + +### Relacoes + +Related to FM-RISK-001, FM-RISK-002, FM-RISK-008 and FM-RISK-009. + +## 7. Low Risks + +## FM-RISK-014 - Analyzer and generator coverage is intentionally partial + +**Severidade:** LOW +**Status:** MITIGATED +**Categoria:** Developer Experience, Testing, Maintainability +**Origem:** Etapa 4 / Entregas 01 and 03; Etapa 5 / Entrega 04 +**Detectado em:** analyzer/generator design decisions +**Componentes afetados:** `Dapper.FluentMap.Analyzers`, `Dapper.FluentMap.Generators` + +### Descricao + +The analyzer and generator detect only statically provable cases. They do not execute map constructors, follow helper methods, simulate scanning, reason about dynamic columns, or prove query-specific materialization validity. + +### Evidencias + +- `docs/sdd/etapa-4/README.md`: runtime remains authority; do not report what cannot be proven statically. +- `docs/sdd/etapa-4/01-roslyn-analyzers.md`: lists many analyzer limitations. +- `docs/sdd/etapa-4/03-source-generator.md`: generator discovers only maps declared in the current compilation. +- `src/Dapper.FluentMap.Analyzers/README.md`: analyzer complements runtime validation and does not replace it. +- `src/Dapper.FluentMap.Generators/README.md`: generator emits registration only for eligible maps in current compilation. + +### Cenario de impacto + +A consumer moves mapping calls into helper methods or uses dynamically computed column names. The analyzer stays silent, and invalid configuration is caught only by runtime validation or query execution. + +### Impacto + +Lower compile-time feedback coverage than consumers might assume. + +### Probabilidade + +Alta. Helper methods and dynamic configuration are common, but the README and SDD make runtime authority clear. + +### Workaround atual + +Call `FluentMapper.Validate()` during startup/tests and keep runtime fail-fast validations enabled. + +### Recomendacao + +Improve analyzer coverage only for patterns that can be proven without false positives. Add documentation examples that pair analyzer use with startup validation. + +### Relacoes + +Related to FM-RISK-003 and FM-RISK-010. + +## FM-RISK-015 - Async `QueryMapped*` overloads are asymmetric: profile async exists, default async does not + +**Severidade:** LOW +**Status:** OPEN +**Categoria:** API Design, Developer Experience +**Origem:** Etapa 5 / Entrega 04 +**Detectado em:** implementation and roadmap +**Componentes afetados:** `QueryMappedExtensions` + +### Descricao + +The current public API includes async overloads for profile queries but not equivalent default `QueryMappedAsync()` and `QueryMappedSingleAsync()` overloads. The SDD lists symmetric async/default overload expansion as a future demand-driven item. + +### Evidencias + +- `docs/sdd/etapa-5/README.md`: P2 item to expand async/default overloads symmetrically if public demand appears. +- `src/Dapper.FluentMap/QueryMappedExtensions.cs`: async methods are present for `` only. +- `test/Dapper.FluentMap.Tests/MappingProfileTests.cs`: validates async concurrent profile queries. + +### Cenario de impacto + +A consumer using default nested mappings in an async data-access layer cannot call a default `QueryMappedAsync()` API. + +### Impacto + +Ergonomic limitation; not a correctness bug. + +### Probabilidade + +Media. Async data access is common, but profile async was prioritized for concurrency validation. + +### Workaround atual + +Use sync `QueryMapped()` for default mappings or introduce an explicit profile when async profile APIs are acceptable. + +### Recomendacao + +Add symmetric default async overloads in a small API-only delivery with integration tests and cancellation-token coverage through `CommandDefinition`. + +### Relacoes + +Related to FM-RISK-005 and Etapa 5 API evolution. + +## FM-RISK-016 - NuGet package metadata remains legacy + +**Severidade:** LOW +**Status:** OPEN +**Categoria:** Documentation, Developer Experience, Packaging, Maintainability +**Origem:** .NET 10 / Entrega 04; Security hardening +**Detectado em:** package validation +**Componentes afetados:** `src/Dapper.FluentMap/*.csproj`, package output + +### Descricao + +Package metadata still uses `PackageLicenseUrl` and does not include a package README, SourceLink or repository metadata modernization. Pack succeeds, but NuGet emits NU5125 and README recommendations. + +### Evidencias + +- `docs/sdd/net10-migration/04-validation-pack-ci.md`: defers metadata modernization. +- `docs/sdd/net10-migration/README.md`: lists metadata modernization as out of scope. +- `docs/sdd/security-hardening/sqlitepclraw-vulnerability.md`: pack succeeds with existing NU5125 and README recommendation. +- `src/Dapper.FluentMap/Dapper.FluentMap.csproj`: contains `PackageLicenseUrl`. +- `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj`: contains `PackageLicenseUrl`. + +### Cenario de impacto + +A package consumer or NuGet UI sees older metadata conventions and missing README even though the package builds correctly. + +### Impacto + +Lower package polish and possible future NuGet warning churn, but no runtime behavior impact. + +### Probabilidade + +Alta. The warning is repeatedly observed during pack. + +### Workaround atual + +None needed for runtime. Treat pack warnings as known until a metadata-only cleanup is scheduled. + +### Recomendacao + +Run a dedicated packaging modernization: replace `PackageLicenseUrl` with `PackageLicenseExpression`, add package README/repository metadata, inspect `.nupkg`, and keep it separate from functional changes. + +### Relacoes + +Related to `.NET 10` migration packaging decisions. + +## FM-RISK-017 - Remote CI execution remains unproven after CI modernization + +**Severidade:** LOW +**Status:** UNKNOWN +**Categoria:** Testing, Maintainability, Developer Experience +**Origem:** .NET 10 / Entregas 04 and 05 +**Detectado em:** validation limitation +**Componentes afetados:** `.github/workflows/ci.yml`, `.appveyor.yml`, `.travis.yml` + +### Descricao + +CI files were updated and locally reviewed, but GitHub Actions, AppVeyor and Travis were not executed remotely from the development environment. + +### Evidencias + +- `docs/sdd/net10-migration/04-validation-pack-ci.md`: remote GitHub Actions, AppVeyor and Travis runs were not executed; Travis availability/image contents unproven. +- `docs/sdd/net10-migration/05-xunit3-migration.md`: CI files reviewed after xUnit 3 migration, but no remote execution evidence. +- `.github/workflows/ci.yml`, `.appveyor.yml`, `.travis.yml`: current CI definitions. + +### Cenario de impacto + +A push triggers CI and discovers that a hosted image, action version, Travis environment, or .NET 10 installation path behaves differently from local validation. + +### Impacto + +CI failure after merge/push, with no evidence of runtime library defect. + +### Probabilidade + +Media. Local command validation is strong, but remote infrastructure can drift. + +### Workaround atual + +Run remote CI before release decisions and treat local validation as necessary but not sufficient. + +### Recomendacao + +After the next push, record actual CI results in the SDD status or release notes. Revisit Travis if the service no longer supports the expected .NET 10 workflow. + +### Relacoes + +Related to `.NET 10` migration validation. + +## FM-RISK-018 - Documentation carries archived/legacy signals alongside new SDD features + +**Severidade:** LOW +**Status:** OPEN +**Categoria:** Documentation, Developer Experience, Maintainability +**Origem:** README and accumulated SDD updates +**Detectado em:** documentation review +**Componentes afetados:** `README.md`, CI badges, consumer-facing docs + +### Descricao + +The README still begins with an archived-project notice and historical CI badges, while later sections document new SDD-era capabilities such as nested materialization, profiles, analyzers, generators and .NET 10 validation. This can confuse readers about maintenance status and supported feature freshness. + +### Evidencias + +- `README.md`: starts with an "Archived" notice from the original project. +- `README.md`: later contains sections for nested object materialization, mapping profiles, trimming/Native AOT, generated registration and Etapa summaries. +- `.github/workflows/ci.yml`: new CI exists, while README badges still point to older AppVeyor/Travis-era links. +- `docs/sdd/etapa-5/README.md`: documents current supported capabilities and limitations. + +### Cenario de impacto + +A consumer reads the top of README, assumes the project is abandoned, then sees modern features and cannot tell which status is authoritative. + +### Impacto + +Documentation trust and adoption risk, not a code correctness risk. + +### Probabilidade + +Media. README is the primary entry point. + +### Workaround atual + +Use SDD reports and current tests as source of truth for recent work. + +### Recomendacao + +Create a documentation-only decision about project status. Either preserve the archived notice as historical context with a current-maintenance note, or move it to an archival/history section. + +### Relacoes + +Related to FM-RISK-016. + +## 8. Cross-Cutting Architectural Concerns + +- Global/static state: `FluentMapper`, Dapper type maps, test reset and Dommel resolver integration remain the main cross-cutting constraint. +- Reflection: expression parsing, assembly scanning, diagnostics, registration inference and runtime materialization all depend on metadata to different degrees. +- Caching: property-map and materialization-plan caches are structured and include profile/column shape where relevant, but cache invalidation still assumes registry-mediated mutation. +- Materialization: `Dapper.Query` remains Dapper-owned for simple mappings; nested/value-object/profile behavior is opt-in through `QueryMapped*`. +- AOT/trimming: explicit/generated registration is the safer path; scanning and `QueryMapped*` remain annotated as sensitive. +- Profiles: query-scoped profiles avoid global `SetTypeMap` swaps, but do not yet cover Dapper multi-mapping, `Dapper.Query` or per-profile conventions. +- Dommel: left stable by design, but not reviewed against the new profile/nested materialization model. + +## 9. Technical Debt Register + +| ID | Debt | Origin | Impact | Suggested Priority | +| -- | ---- | ------ | ------ | ------------------ | +| FM-RISK-001 | Global/static state and disabled test parallelism | Etapa 1 | Blocks stronger concurrency guarantees | P1 | +| FM-RISK-002 | Public mutable mapping dictionaries | Etapa 1 | Can bypass validation/cache invalidation | P1 | +| FM-RISK-004 | No generated `DbDataReader` materializer | Etapa 5 | AOT/performance limitation | P1 | +| FM-RISK-005 | No streaming/unbuffered `QueryMapped*` | Etapa 5 | Memory/performance limitation | P1 | +| FM-RISK-008 | No per-profile conventions/naming policies | Etapa 5 | Boilerplate and profile extensibility limit | P1 | +| FM-RISK-006 | No Value Object factory API | Etapa 5 | Common domain model limitation | P2 | +| FM-RISK-009 | No profile integration for Dapper multi-mapping | Etapa 5 | Advanced Dapper scenarios uncovered | P2 | +| FM-RISK-015 | Missing default async `QueryMapped*` overloads | Etapa 5 | API ergonomics | P2 | +| FM-RISK-016 | Legacy NuGet metadata | .NET 10 migration | Package polish/warnings | P2 | +| FM-RISK-018 | README maintenance-status inconsistency | README/SDD | Consumer confusion | P2 | +| FM-RISK-017 | Remote CI evidence missing | .NET 10 migration | Release confidence | P2 | +| FM-RISK-010 | Legacy assembly scanning API behavior | Etapa 3 | Maintenance/diagnostic debt | P3 | +| FM-RISK-012 | Throwing sentinel `IgnoredPropertyInfo` | Etapa 2 | Future compatibility debt | P3 | +| FM-RISK-014 | Partial analyzer/generator coverage | Etapa 4 | Compile-time feedback gaps | P3 | +| FM-RISK-011 | Dapper-delegated constructor edge cases | Etapa 3 | Edge-case diagnostics | P3 | +| FM-RISK-013 | Dommel profile/nested review missing | Etapa 5 | Extension clarity | P3 | +| FM-RISK-007 | Reflective TypeHandler adapter | Etapa 5 | Dapper upgrade fragility | P3 | +| FM-RISK-003 | Scanning unsafe under trimming/AOT | Etapa 4 | Compatibility risk if warnings ignored | P3, unless AOT-focused release | + +## 10. Historical Issues Already Resolved + +| Problem | Origin | Resolved In | Evidence | +| ------- | ------ | ----------- | -------- | +| ReflectionHelper could resolve a homonymous method/member instead of the expression property | Etapa 1 / Entrega 01 | Etapa 1 / Entrega 01 | `docs/sdd/etapa-1/01-reflection-helper.md`; `src/Dapper.FluentMap/Utils/ReflectionHelper.cs`; `test/Dapper.FluentMap.Tests/ReflectionHelperTests.cs` | +| Explicit mapping and convention order caused "last SetTypeMap wins" behavior | Etapa 1 / Entrega 02 | Etapa 1 / Entrega 02 and 04 | `docs/sdd/etapa-1/02-mapping-composition.md`; `src/Dapper.FluentMap/MappingRegistry.cs`; `test/Dapper.FluentMap.Tests/MappingCompositionTests.cs` | +| Old string-concatenated mapping cache could retain stale/mis-keyed hits/misses | Etapa 1 / Entrega 03 | Etapa 1 / Entrega 04 | `docs/sdd/etapa-1/04-mapping-registry-cache.md`; `src/Dapper.FluentMap/MappingCacheKey.cs`; `test/Dapper.FluentMap.Tests/MappingRegistryTests.cs` | +| No atomic internal reset for tests | Etapa 1 / Entrega 03 | Etapa 1 / Entrega 04 | `docs/sdd/etapa-1/04-mapping-registry-cache.md`; `src/Dapper.FluentMap/MappingRegistry.cs` | +| Nested paths with same terminal name, such as `Rank.Level` and `Seniority.Level`, were treated as duplicate | Etapa 2 / Entrega 01 | Etapa 2 / Entrega 01 | `docs/sdd/etapa-2/01-member-path.md`; `src/Dapper.FluentMap/Mapping/MemberPath.cs`; `test/Dapper.FluentMap.Tests/MemberPathTests.cs` | +| Configuration errors used generic/late exceptions for many invalid mappings | Etapa 2 / Entrega 02 | Etapa 2 / Entrega 02 and Etapa 3 / Entrega 03 | `docs/sdd/etapa-2/02-configuration-validation.md`; `src/Dapper.FluentMap/FluentMapConfigurationException.cs`; `src/Dapper.FluentMap/MappingConfigurationValidator.cs`; `FluentMapper.Validate()` | +| No inherited mapping composition | Etapa 2 / Entrega 03 | Etapa 2 / Entrega 03 | `docs/sdd/etapa-2/03-inherited-mappings.md`; `test/Dapper.FluentMap.Tests/InheritedMappingTests.cs` | +| No declarative naming policy API | Etapa 2 / Entrega 04 | Etapa 2 / Entrega 04 | `docs/sdd/etapa-2/04-naming-policies.md`; `src/Dapper.FluentMap/Naming/NamingPolicy.cs`; `test/Dapper.FluentMap.Tests/NamingPolicyTests.cs` | +| No explicit generic registration or deterministic modern assembly scanning | Etapa 3 / Entrega 01 | Etapa 3 / Entrega 01 | `docs/sdd/etapa-3/01-mapping-registration.md`; `test/Dapper.FluentMap.Tests/MappingRegistrationTests.cs` | +| Mapped columns did not influence Dapper constructor mapping for immutable simple models | Etapa 3 / Entrega 02 | Etapa 3 / Entrega 02 | `docs/sdd/etapa-3/02-constructor-immutable-mapping.md`; `src/Dapper.FluentMap/TypeMaps/FluentConstructorTypeMap.cs`; `test/Dapper.FluentMap.Tests/ConstructorMappingTests.cs` | +| No public aggregate validation/explain diagnostics | Etapa 2 and Etapa 3 | Etapa 3 / Entrega 03 | `docs/sdd/etapa-3/03-diagnostics-api.md`; `src/Dapper.FluentMap/Diagnostics/*`; `test/Dapper.FluentMap.Tests/DiagnosticsApiTests.cs` | +| Runtime registration created type maps via `MakeGenericType` and `Activator.CreateInstance` | Etapa 3 / Entrega 01 | Etapa 4 / Entrega 02 | `docs/sdd/etapa-4/02-trimming-aot.md`; `src/Dapper.FluentMap/MappingRegistry.cs` | +| No source generator for mapping registration | Etapa 4 planning | Etapa 4 / Entrega 03 | `docs/sdd/etapa-4/03-source-generator.md`; `src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs` | +| Nested paths returned to Dapper could write leaf values into the root object slot | Etapa 5 / Entrega 01 | Etapa 5 / Entrega 02 | `docs/sdd/etapa-5/01-nested-materialization-spike.md`; `docs/sdd/etapa-5/02-nested-object-materialization.md`; `test/Dapper.FluentMap.Tests/NestedMaterializationSpikeTests.cs` | +| Mutable nested object materialization was unsupported | Etapa 5 / Entrega 01 | Etapa 5 / Entrega 02 | `docs/sdd/etapa-5/02-nested-object-materialization.md`; `test/Dapper.FluentMap.Tests/NestedObjectMaterializationTests.cs` | +| Immutable nested Value Objects were unsupported | Etapa 5 / Entrega 01-02 | Etapa 5 / Entrega 03 | `docs/sdd/etapa-5/03-value-objects.md`; `test/Dapper.FluentMap.Tests/ValueObjectMaterializationTests.cs` | +| Same entity could not have multiple query-scoped mapping profiles | Etapa 3 limitations | Etapa 5 / Entrega 04 | `docs/sdd/etapa-5/04-mapping-profiles.md`; `test/Dapper.FluentMap.Tests/MappingProfileTests.cs` | +| Tests targeted obsolete `netcoreapp3.1` and could not run on the local machine | .NET 10 / Entrega 01 | .NET 10 / Entrega 02 | `docs/sdd/net10-migration/01-inventory-baseline.md`; `docs/sdd/net10-migration/02-test-projects-net10.md` | +| xUnit 2 was deprecated/legacy in package diagnostics | .NET 10 / Entrega 01-04 | .NET 10 / Entrega 05 | `docs/sdd/net10-migration/05-xunit3-migration.md`; test `.csproj` files | +| Vulnerable test transitive `SQLitePCLRaw.lib.e_sqlite3 2.1.11` | .NET 10 migration | Security hardening | `docs/sdd/security-hardening/sqlitepclraw-vulnerability.md`; `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | + +## 11. Unknown / Requires Investigation + +- FM-RISK-013: Dommel interaction with profiles/nested materialization requires a dedicated review. Current evidence only proves legacy/default Dommel behavior. +- FM-RISK-017: CI needs actual remote execution evidence after modernization. +- Native AOT full runtime behavior remains unproven locally because the platform linker was missing during Etapa 4 and Etapa 5 validation. +- Current external action/service versions were not verified during this audit; the report relies on repository evidence, not live CI execution. + +## 12. Recommended Remediation Order + +1. Document and constrain the configuration lifecycle around global/static state before changing implementation. This reduces ambiguity for FM-RISK-001 and FM-RISK-002. +2. Plan a compatibility-safe path away from public mutable dictionaries, likely with read-only views and a future-major migration note. +3. Implement a generated `DbDataReader` materializer, because it unlocks the biggest cluster: FM-RISK-004, FM-RISK-005 and part of FM-RISK-007. +4. Add streaming/unbuffered `QueryMapped*` only after reader lifetime semantics are designed. +5. Design per-profile conventions/naming policies before expanding profile APIs further. +6. Add default async `QueryMapped*` overloads with `CommandDefinition`/cancellation coverage. +7. Run a dedicated Dommel profile/nested review and document whether integration is intentionally unsupported. +8. Modernize NuGet metadata and README status in a documentation/packaging-only delivery. +9. Record remote CI outcomes after the next push. +10. Revisit lower-level compatibility debt: `IgnoredPropertyInfo`, reflective TypeHandler adapter and legacy scanning API. + +## 13. Architectural Health Assessment + +### Strengths + +- The project has unusually strong SDD traceability for a small library. +- Public behavior is protected by integration tests using real Dapper and SQLite. +- Precedence is explicit and repeatedly validated: explicit, inherited explicit, convention/naming policy, Dapper default. +- `MemberPath` removed a class of reflection/name-collision bugs and made nested/profiles possible. +- Runtime validation remains authoritative even after analyzers and generator were added. +- Profiles avoid unsafe temporary `SqlMapper.SetTypeMap` mutation by using query-scoped materialization. +- Published source projects remain `netstandard2.0`, preserving broad compatibility. + +### Concerns + +- Global mutable state and public mutable dictionaries remain the central architectural debt. +- AOT/trimming support is split: explicit/generated registration is good, scanning and `QueryMapped*` remain constrained. +- The runtime materializer has real scope but currently lacks generated and streaming variants. +- Dommel was intentionally not evolved with the core profile/nested model. +- README/package metadata still carry legacy signals. + +### Evolution Risks + +- Adding per-profile conventions, streaming, factory methods or generated materializers will touch shared registry/materialization/cache contracts. +- Removing or hiding public dictionaries would be a compatibility-sensitive major-version decision. +- Future Dapper updates need targeted review around `ITypeMap`, `IMemberMap`, constructor mapping and TypeHandler internals. +- Expanding Dommel support could reintroduce global state concerns if it tries to observe profiles through Dapper's global type-map path. + +### Overall Assessment + +**Moderate technical risk** + +The core design is coherent and much healthier than the historical baseline: the main correctness bugs around expression resolution, mapping composition, cache keys, nested path identity and profile concurrency have been addressed. The remaining risk is moderate because the library still carries global mutable compatibility surfaces and the newest materialization capabilities depend on runtime reflection/dynamic code. There is no evidence of a current critical production-unsafety condition when the documented startup-once and opt-in APIs are used. diff --git a/test/Dapper.FluentMap.Tests/ConfigurationLifecycleTests.cs b/test/Dapper.FluentMap.Tests/ConfigurationLifecycleTests.cs new file mode 100644 index 0000000..d9e206a --- /dev/null +++ b/test/Dapper.FluentMap.Tests/ConfigurationLifecycleTests.cs @@ -0,0 +1,149 @@ +using System; +using Dapper; +using Dapper.FluentMap.Conventions; +using Dapper.FluentMap.Mapping; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class ConfigurationLifecycleTests + { + [Fact] + public void InitializeShouldAllowAdditiveConfigurationAcrossRepeatedCalls() + { + ResetMapper(typeof(FirstLifecycleEntity), typeof(SecondLifecycleEntity)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new FirstLifecycleMap())); + FluentMapper.Initialize(c => c.AddMap(new SecondLifecycleMap())); + + Assert.IsType(FluentMapper.EntityMaps[typeof(FirstLifecycleEntity)]); + Assert.IsType(FluentMapper.EntityMaps[typeof(SecondLifecycleEntity)]); + Assert.Equal(2, FluentMapper.EntityMaps.Count); + } + finally + { + ResetMapper(typeof(FirstLifecycleEntity), typeof(SecondLifecycleEntity)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void RuntimeRegistrationShouldRemainCompatibleWhenAccessIsSerialized() + { + ResetMapper(typeof(RuntimeConventionEntity)); + + try + { + using (var connection = OpenConnection()) + { + var beforeConfiguration = connection.QuerySingle( + "SELECT 1 AS Id, 'before' AS Name;"); + + FluentMapper.Initialize(c => c.AddConvention().ForEntity()); + + var afterConfiguration = connection.QuerySingle( + "SELECT 2 AS cfgId, 'after' AS cfgName;"); + + Assert.Equal(1, beforeConfiguration.Id); + Assert.Equal("before", beforeConfiguration.Name); + Assert.Equal(2, afterConfiguration.Id); + Assert.Equal("after", afterConfiguration.Name); + } + } + finally + { + ResetMapper(typeof(RuntimeConventionEntity)); + } + } + + [Fact] + public void DirectEntityMapsMutationShouldRemainLegacySurfaceAndBypassDapperTypeMapInstallation() + { + ResetMapper(typeof(DirectMutationEntity)); + + try + { + var added = FluentMapper.EntityMaps.TryAdd(typeof(DirectMutationEntity), new DirectMutationMap()); + var member = SqlMapper.GetTypeMap(typeof(DirectMutationEntity)).GetMember("legacy_id"); + + Assert.True(added); + Assert.Null(member); + Assert.IsType(FluentMapper.EntityMaps[typeof(DirectMutationEntity)]); + } + finally + { + ResetMapper(typeof(DirectMutationEntity)); + } + } + + private static SqliteConnection OpenConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + + private static void ResetMapper(params Type[] types) + { + FluentMapper.Reset(types); + } + + private sealed class FirstLifecycleEntity + { + public int Id { get; set; } + } + + private sealed class FirstLifecycleMap : EntityMap + { + public FirstLifecycleMap() + { + Map(entity => entity.Id).ToColumn("first_id"); + } + } + + private sealed class SecondLifecycleEntity + { + public string Name { get; set; } + } + + private sealed class SecondLifecycleMap : EntityMap + { + public SecondLifecycleMap() + { + Map(entity => entity.Name).ToColumn("second_name"); + } + } + + private sealed class RuntimeConventionEntity + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private sealed class RuntimePrefixConvention : Convention + { + public RuntimePrefixConvention() + { + Properties() + .Configure(configuration => configuration.HasPrefix("cfg")); + } + } + + private sealed class DirectMutationEntity + { + public int Id { get; set; } + } + + private sealed class DirectMutationMap : EntityMap + { + public DirectMutationMap() + { + Map(entity => entity.Id).ToColumn("legacy_id"); + } + } + } +} From 6166518d985aaef684486d90b105773a0281c889 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sun, 26 Jul 2026 19:06:40 -0300 Subject: [PATCH 16/20] refactor(fluentmap): encapsulate mapping state mutations --- README.md | 2 +- .../etapa-6/02-mapping-state-encapsulation.md | 265 +++++++++++++++ docs/sdd/etapa-6/README.md | 8 +- docs/sdd/etapa-6/decisions.md | 38 +++ docs/sdd/etapa-6/handoff.md | 98 +++++- docs/sdd/fluentmap-risk-assessment.md | 8 +- src/Dapper.FluentMap/FluentMapper.cs | 28 ++ src/Dapper.FluentMap/MappingRegistry.cs | 21 ++ .../MappingStateEncapsulationTests.cs | 310 ++++++++++++++++++ 9 files changed, 762 insertions(+), 16 deletions(-) create mode 100644 docs/sdd/etapa-6/02-mapping-state-encapsulation.md create mode 100644 test/Dapper.FluentMap.Tests/MappingStateEncapsulationTests.cs diff --git a/README.md b/README.md index 7958391..6d9f95b 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ Operational Phase Configure FluentMap during application startup, optionally call `FluentMapper.Validate()`, then treat the effective configuration as read-only once queries begin. `FluentMapper.Initialize(...)` can still be called more than once for additive configuration, subject to the existing duplicate-map validations, but runtime reconfiguration is not a concurrency contract. -For compatibility, the public registration APIs still mutate the global registry immediately. If an application changes mappings after queries have started, it must guarantee external quiescence for the affected types: no concurrent queries, no active materializers, and no competing `SqlMapper.SetTypeMap` changes. Direct mutation of `FluentMapper.EntityMaps` or `FluentMapper.TypeConventions` is a legacy compatibility surface and can bypass validation, cache invalidation and Dapper type-map installation; prefer `Initialize(...)` and the fluent registration APIs. +For compatibility, the public registration APIs still mutate the global registry immediately. If an application changes mappings after queries have started, it must guarantee external quiescence for the affected types: no concurrent queries, no active materializers, and no competing `SqlMapper.SetTypeMap` changes. Direct mutation of `FluentMapper.EntityMaps` or `FluentMapper.TypeConventions` is a legacy compatibility surface and can bypass validation, cache invalidation and Dapper type-map installation; prefer `Initialize(...)` and the fluent registration APIs. For read-only inspection, prefer `FluentMapper.GetEntityMaps()` and `FluentMapper.GetTypeConventions()` snapshots. **Initialization:** ```csharp diff --git a/docs/sdd/etapa-6/02-mapping-state-encapsulation.md b/docs/sdd/etapa-6/02-mapping-state-encapsulation.md new file mode 100644 index 0000000..4437d57 --- /dev/null +++ b/docs/sdd/etapa-6/02-mapping-state-encapsulation.md @@ -0,0 +1,265 @@ +# 02 - Mapping State Encapsulation + +## Current Exposure + +`FluentMapper.EntityMaps` exposes: + +```csharp +public static readonly ConcurrentDictionary EntityMaps +``` + +`FluentMapper.TypeConventions` exposes: + +```csharp +public static readonly ConcurrentDictionary> TypeConventions +``` + +Both fields are `readonly` only at the field-reference level. Consumers cannot assign a different dictionary to the field, but they can mutate the exposed `ConcurrentDictionary` instance and the mutable `IList` values. + +Available mutation operations include `TryAdd`, index assignment, `Remove`, `Clear` and explicit interface `Add` through `IDictionary` / `ICollection>`. + +Delivery 01 is confirmed as `COMPLETED` in `docs/sdd/etapa-6/README.md`. Its lifecycle decision is authoritative for this delivery: + +```text +Configuration Phase + | + v +Operational Phase +``` + +Configuration after the operational phase remains compatibility-only and requires external quiescence. Direct dictionary mutation is legacy compatibility debt, not a supported deterministic configuration path. + +## Mutation Paths + +| Mutation path | Validates | Invalidates cache | Updates Dapper | Supported | +| ------------- | --------- | ----------------- | -------------- | --------- | +| `Initialize(c => c.AddMap(map))` | Yes | Yes | Yes | Yes, during configuration phase | +| `Initialize(c => c.AddMap())` | Yes | Yes | Yes | Yes, during configuration phase | +| `Initialize(c => c.AddMapsFromAssembly(...))` | Yes | Yes | Yes | Yes, trimming-sensitive | +| `Initialize(c => c.AddProfile())` | Yes | Yes | No | Yes, query-scoped profiles only | +| `Initialize(c => c.AddConvention().ForEntity())` | Yes | Yes | Yes | Yes, during configuration phase | +| `Initialize(c => c.UseNamingPolicy(...).ForEntity())` | Yes | Yes | Yes | Yes, during configuration phase | +| `((IDictionary)FluentMapper.EntityMaps).Add(...)` | No | No | No | Legacy compatibility only | +| `FluentMapper.EntityMaps.TryAdd(...)` | No | No | No | Legacy compatibility only | +| `FluentMapper.EntityMaps[type] = map` | No | No | No | Legacy compatibility only | +| `FluentMapper.EntityMaps.Remove(...)` | No | No | No | Legacy compatibility only | +| `FluentMapper.EntityMaps.Clear()` | No | No | No | Legacy compatibility only | +| `((IDictionary>)FluentMapper.TypeConventions).Add(...)` | No | No | No | Legacy compatibility only | +| `FluentMapper.TypeConventions.TryAdd(...)` | No | No | No | Legacy compatibility only | +| `FluentMapper.TypeConventions[type] = list` | No | No | No | Legacy compatibility only | +| `FluentMapper.TypeConventions[type].Add(...)` | No | No | No | Legacy compatibility only | +| `FluentMapper.TypeConventions.Remove(...)` | No | No | No | Legacy compatibility only | +| `FluentMapper.TypeConventions.Clear()` | No | No | No | Legacy compatibility only | +| `FluentMapper.Reset(...)` | No public validation | Clears all caches | Removes requested type maps | Internal test isolation only | + +## Problem + +`MappingRegistry` is the intended mutation boundary. It validates entity maps, validates conventions, invalidates property-map/materialization-plan caches, and installs the default Dapper type map through `SqlMapper.SetTypeMap`. + +The public dictionaries expose the registry storage directly. As a result, consumers can add, replace or remove maps and conventions without the registry observing the mutation. This can produce stale cache entries, missing Dapper type-map installation, or diagnostics that disagree with query behavior. + +## Compatibility Constraints + +- The public fields cannot be removed in this delivery without source and binary breakage. +- Changing their declared type from `ConcurrentDictionary<...>` to a read-only interface would be source and binary breaking. +- Replacing the instances with non-mutable wrappers is impossible without changing the field type. +- Marking the fields with `[Obsolete]` is source-compatible and binary-compatible, but can break consumers that treat warnings as errors. +- Blocking runtime mutation would contradict Delivery 01 unless a major-version migration is planned. +- `Dommel` currently reads these fields directly; this delivery must not redesign Dommel. + +Compatibility impact matrix: + +| Possible change | Source breaking | Binary breaking | Behavior breaking | Decision | +| --------------- | --------------- | --------------- | ----------------- | -------- | +| Remove public fields | Yes | Yes | Yes | Rejected | +| Change field types to read-only interfaces | Yes | Yes | Yes | Rejected | +| Replace fields with read-only properties of same names | Yes | Yes | Yes | Rejected | +| Keep fields and add read-only APIs | No | No | No | Accepted | +| Mark fields `[Obsolete]` | Warning-only, but can fail warnings-as-errors builds | No | No | Deferred | +| Throw on runtime mutation through official APIs | No | No | Yes | Rejected for this delivery | +| Detect all external direct mutations | No reliable path with current field types | No reliable path with current field types | Could be partial/inconsistent | Rejected | + +## Goals + +- Provide official read-only accessors for mapping state inspection. +- Keep new read-only access snapshot-based so consumers cannot mutate registry collections through the new API. +- Keep all official mutations conceptually behind `FluentMapper -> MappingRegistry`. +- Preserve existing public fields for source and binary compatibility. +- Document direct dictionary mutation as a legacy compatibility surface. +- Preserve lifecycle, precedence, profiles, naming policies, inherited maps, cache invalidation and Dapper integration. + +## Non-Goals + +- Remove `EntityMaps` or `TypeConventions`. +- Change the declared type of existing public fields. +- Add runtime freezing/sealing. +- Detect every possible direct mutation of legacy dictionaries. +- Make `IEntityMap`, `Convention` or `PropertyMap` immutable. +- Redesign Dommel. +- Implement a generated materializer. + +## Proposed Encapsulation Strategy + +Add snapshot-based read-only APIs: + +```csharp +FluentMapper.GetEntityMaps() +FluentMapper.GetTypeConventions() +``` + +These APIs return read-only snapshots of the current default entity maps and type conventions. The snapshots do not expose `ConcurrentDictionary` or mutable convention lists. They are intended for diagnostics, inspection and migration away from direct dictionary reads. + +The existing public fields remain as legacy compatibility surface. Their XML documentation is updated to tell consumers to use fluent registration APIs for mutation and read-only snapshots for inspection. + +The registry remains the mutation owner for official APIs: + +```text +Consumer API + | + v +FluentMapper / FluentMapConfiguration + | + v +MappingRegistry + | + v +Validation + | + v +Cache invalidation + | + v +Dapper integration +``` + +No cache invalidation or validation logic is duplicated outside `MappingRegistry`. + +## Migration Strategy + +Minor-compatible migration: + +- New code should use `Initialize(...)`, `AddMap(...)`, `AddProfile(...)`, convention APIs and naming policies for mutation. +- New code that only needs to inspect mappings should use `GetEntityMaps()` and `GetTypeConventions()`. +- Existing code that mutates `EntityMaps` or `TypeConventions` continues to compile and run, but remains legacy and can bypass invariants. + +Future major-version migration: + +- Replace public mutable fields with read-only properties. +- Move mutable state behind registry-owned methods only. +- Consider immutable snapshots for effective mapping state after configuration. +- Consider an explicit compatibility adapter for Dommel instead of direct dictionary reads. + +## Public API Impact + +Added public APIs: + +```csharp +public static IReadOnlyDictionary GetEntityMaps() +public static IReadOnlyDictionary> GetTypeConventions() +``` + +Preserved public APIs: + +```csharp +public static readonly ConcurrentDictionary EntityMaps +public static readonly ConcurrentDictionary> TypeConventions +``` + +The legacy fields are not marked `[Obsolete]` in this delivery. The reason is compatibility risk for consumers that compile with warnings as errors. + +## Internal API Impact + +`MappingRegistry` adds snapshot builders for entity maps and type conventions. They copy the dictionary contents and copy convention lists into read-only collections. + +No registry mutation rule is moved out of `MappingRegistry`. + +## Implementation + +Implemented: + +- `FluentMapper.GetEntityMaps()`; +- `FluentMapper.GetTypeConventions()`; +- `MappingRegistry.GetEntityMapsSnapshot()`; +- `MappingRegistry.GetTypeConventionsSnapshot()`; +- XML documentation remarks on `EntityMaps` and `TypeConventions`; +- README guidance to prefer snapshots for read-only inspection. + +Not implemented: + +- `[Obsolete]` attributes on legacy fields; +- freeze/seal lifecycle enforcement; +- automatic direct-mutation detection; +- immutable effective mapping state; +- Dommel redesign. + +## Acceptance Criteria + +- Delivery 02 SDD document exists. +- Stage README marks Delivery 02 progress and final completion. +- `EntityMaps` and `TypeConventions` public signatures are documented. +- Mutation paths and bypass behavior are documented. +- Official read-only snapshot APIs are implemented and tested. +- Official map/convention registration still validates, invalidates cache and updates Dapper as before. +- Profile behavior remains query-scoped and does not update Dapper type maps. +- Legacy direct mutation remains possible but is characterized as bypassing registry invariants. +- FM-RISK-001 and FM-RISK-002 are reviewed. +- Restore, build, tests and pack are executed and recorded. +- A single semantic commit is created. + +## Residual Risk + +Direct mutation remains possible through `EntityMaps`, `TypeConventions`, mutable `IEntityMap.PropertyMaps`, mutable `Convention.PropertyMaps` and mutable `Convention.ConventionConfigurations`. Because the public field signatures expose concrete mutable collections, full prevention requires a major-version compatibility break. + +The new read-only APIs reduce risk for inspection and migration, but they do not make the legacy surface safe. + +## Validation Results + +Environment: + +- SDK: `10.0.302` +- test runner detected: VSTest with xUnit v3 +- core target: `netstandard2.0` +- test target: `net10.0` + +Localized validation: + +```text +dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --filter "FullyQualifiedName~MappingStateEncapsulationTests" +``` + +Result: + +- success; +- 6 tests passed. + +Mandatory validation: + +```text +dotnet restore .\Dapper.FluentMap.sln +dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore +dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build +dotnet pack .\Dapper.FluentMap.sln --configuration Release --no-build --output .\artifacts\packages +``` + +Results: + +- restore: success; +- build: success, 0 warnings, 0 errors; +- tests: success, 221 total tests passed: + - core: 190; + - Dommel: 7; + - analyzers: 9; + - generators: 14; + - generated-registration integration: 1; +- pack: success: + - `Dapper.FluentMap.2.0.0.nupkg`; + - `Dapper.FluentMap.Dommel.2.0.0.nupkg`; + - `Dapper.FluentMap.Analyzers.2.0.0.nupkg`; + - `Dapper.FluentMap.Generators.2.0.0.nupkg`. + +Known pack warnings: + +- `NU5125` for legacy `PackageLicenseUrl` in core and Dommel; +- NuGet README recommendation for core and Dommel. + +These warnings are pre-existing package metadata debt tracked outside this delivery. diff --git a/docs/sdd/etapa-6/README.md b/docs/sdd/etapa-6/README.md index af198bc..57c571d 100644 --- a/docs/sdd/etapa-6/README.md +++ b/docs/sdd/etapa-6/README.md @@ -11,15 +11,15 @@ Esta etapa preserva a compatibilidade publica existente do core `Dapper.FluentMa | Delivery | Title | Status | Notes | |---|---|---|---| | 01 | Configuration Lifecycle | COMPLETED | Lifecycle suportado e mutacoes de runtime formalizados. | -| 02 | Mapping State Encapsulation | NEXT | Planejar reducao segura da exposicao mutavel de estado. | -| 03 | Dapper Compatibility Adapters | PENDING | Isolar contratos de compatibilidade com Dapper. | +| 02 | Mapping State Encapsulation | COMPLETED | Snapshots read-only adicionados e superficie mutavel legada documentada. | +| 03 | Dapper Compatibility Adapters | NEXT | Isolar contratos de compatibilidade com Dapper. | | 04 | Generated Materializer Spike | PENDING | Investigar materializer gerado para `DbDataReader`. | ## Delivery List 01 Configuration Lifecycle -> COMPLETED -02 Mapping State Encapsulation -> NEXT -03 Dapper Compatibility Adapters -> PENDING +02 Mapping State Encapsulation -> COMPLETED +03 Dapper Compatibility Adapters -> NEXT 04 Generated Materializer Spike -> PENDING ## Sources Of Truth diff --git a/docs/sdd/etapa-6/decisions.md b/docs/sdd/etapa-6/decisions.md index e693e69..0b003a2 100644 --- a/docs/sdd/etapa-6/decisions.md +++ b/docs/sdd/etapa-6/decisions.md @@ -45,3 +45,41 @@ O contrato preservado e: - Conventions e naming policies permanecem por entidade e sao lidas por profiles sem mutacao global por query. Qualquer entrega futura que tente aplicar profiles ao caminho `Dapper.Query()`, multi-mapping ou Dommel deve tratar isso como nova decisao arquitetural. + +## E6-D004 - Mapping State Read-Only Snapshots + +Entrega 02 escolhe encapsulamento incremental sem breaking change. + +`FluentMapper.EntityMaps` e `FluentMapper.TypeConventions` permanecem campos publicos mutaveis do mesmo tipo para preservar compatibilidade de fonte e binaria. Eles nao foram marcados com `[Obsolete]` nesta entrega porque isso poderia quebrar consumidores que tratam warnings como erros. + +Novas APIs publicas de leitura foram adicionadas: + +```csharp +FluentMapper.GetEntityMaps() +FluentMapper.GetTypeConventions() +``` + +Elas retornam snapshots read-only, nao o `ConcurrentDictionary` vivo nem listas mutaveis de conventions. O objetivo e oferecer uma superficie oficial para inspecao e migracao sem permitir mutacao acidental pelo novo caminho. + +Toda mutacao oficial continua passando conceitualmente por: + +```text +Consumer API + | + v +FluentMapper / FluentMapConfiguration + | + v +MappingRegistry + | + v +Validation + | + v +Cache invalidation + | + v +Dapper integration +``` + +Mutacoes diretas nos campos legados continuam possiveis, podem ignorar invariantes e exigem migracao futura de major version para serem removidas ou substituidas por propriedades read-only. diff --git a/docs/sdd/etapa-6/handoff.md b/docs/sdd/etapa-6/handoff.md index 58d651a..eb78082 100644 --- a/docs/sdd/etapa-6/handoff.md +++ b/docs/sdd/etapa-6/handoff.md @@ -2,7 +2,7 @@ ## Last Completed Delivery -01 - Configuration Lifecycle +02 — Mapping State Encapsulation ## Current Architecture @@ -14,6 +14,13 @@ - public mutable `TypeConventions`; - Dapper global type-map integration through `SqlMapper.SetTypeMap`. +Delivery 02 added read-only snapshot APIs: + +- `FluentMapper.GetEntityMaps()`; +- `FluentMapper.GetTypeConventions()`. + +These APIs return snapshot collections for inspection and do not expose the live `ConcurrentDictionary` instances or mutable convention lists. + The supported lifecycle is now documented as: ```text @@ -32,20 +39,92 @@ Profiles remain query-scoped through `QueryMapped()` and do no - E6-D001 - Configuration lifecycle is startup configuration followed by read-only operation. - E6-D002 - Delivery 01 chose Documentation Contract Only; no `Freeze()`, no sealing API and no runtime enforcement. - E6-D003 - Profiles remain query-scoped and must not be implemented by temporary `SqlMapper.SetTypeMap` mutation. +- E6-D004 - Mapping state read-only snapshots are the minor-compatible encapsulation path; mutable public fields remain legacy compatibility surface. + +## Mapping State After Delivery 02 + +Official mutation paths still go through `FluentMapConfiguration` and `MappingRegistry`: + +```text +Consumer API + | + v +FluentMapper / FluentMapConfiguration + | + v +MappingRegistry + | + v +Validation + | + v +Cache invalidation + | + v +Dapper integration +``` + +Read-only inspection should use `GetEntityMaps()` and `GetTypeConventions()`. These are snapshots, so later registrations do not mutate a previously returned view. + +## Public Compatibility Surfaces Still Present + +- `FluentMapper.EntityMaps` remains `public static readonly ConcurrentDictionary`. +- `FluentMapper.TypeConventions` remains `public static readonly ConcurrentDictionary>`. +- `IEntityMap.PropertyMaps` remains mutable. +- `Convention.PropertyMaps` and `Convention.ConventionConfigurations` remain mutable. +- Direct mutation through these surfaces remains possible and can bypass registry invariants. -## Files Changed +## Registry Invariants + +- Official `AddMap(...)` validates before storage. +- Official `AddProfile()` validates before storage. +- Official convention/naming-policy registration validates before storage. +- Duplicate default maps and duplicate profiles remain rejected by the registry. +- Profiles remain stored in `ProfileMaps[(EntityType, ProfileType)]` and are not exposed by `GetEntityMaps()`. + +## Cache Invariants + +- Official default map registration invalidates property-map and materialization-plan cache entries for the entity type and reinstalls the Dapper type map. +- Official profile registration invalidates caches for the entity type and does not call `SqlMapper.SetTypeMap`. +- Official convention/naming-policy registration invalidates caches for the entity type and reinstalls the Dapper type map. +- Direct mutation of legacy public dictionaries does not invalidate caches. + +## Decisions Delivery 03 Must Preserve + +- Do not make profiles visible to `Dapper.Query()` by mutating global Dapper type maps per operation. +- Do not duplicate validation or cache invalidation outside `MappingRegistry`. +- Prefer adapters around Dapper compatibility boundaries over widening public mutable state. +- Keep `GetEntityMaps()` and `GetTypeConventions()` as read-only inspection snapshots. +- Treat removal or type changes of `EntityMaps`/`TypeConventions` as future major-version work. + +## Remaining Dapper-Specific Technical Debt + +- `SqlMapper.SetTypeMap` remains process-global state. +- Dommel still reads public legacy mapping dictionaries directly. +- Profiles are not supported through `Dapper.Query()` or Dapper multi-mapping. +- The runtime materializer still uses reflection/dynamic-code paths for `QueryMapped*`. +- Dapper compatibility details around type maps, constructor mapping and handlers remain candidates for Delivery 03. + +## Files Changed In Delivery 02 - `README.md` - `docs/sdd/fluentmap-risk-assessment.md` - `docs/sdd/etapa-6/README.md` - `docs/sdd/etapa-6/decisions.md` - `docs/sdd/etapa-6/handoff.md` -- `docs/sdd/etapa-6/01-configuration-lifecycle.md` -- `test/Dapper.FluentMap.Tests/ConfigurationLifecycleTests.cs` +- `docs/sdd/etapa-6/02-mapping-state-encapsulation.md` +- `src/Dapper.FluentMap/FluentMapper.cs` +- `src/Dapper.FluentMap/MappingRegistry.cs` +- `test/Dapper.FluentMap.Tests/MappingStateEncapsulationTests.cs` ## Public API Impact -No public API was added, removed, renamed or marked obsolete. +New public API was added: + +- `FluentMapper.GetEntityMaps()`; +- `FluentMapper.GetTypeConventions()`. + +No public API was removed, renamed or marked obsolete. The public documentation now states: @@ -54,24 +133,25 @@ The public documentation now states: - treat configuration as read-only once queries begin; - runtime mutation after queries is compatibility-only and requires external quiescence; - direct dictionary mutation is legacy and can bypass validation, cache invalidation and Dapper type-map installation. +- read-only inspection should use snapshot APIs. ## Remaining Risks - FM-RISK-001 remains mitigated, not resolved: global FluentMap/Dapper state still exists. -- FM-RISK-002 remains open: public mutable dictionaries can still bypass registry validation/cache invalidation. +- FM-RISK-002 remains open with mitigation: read-only snapshots now exist, but public mutable dictionaries can still bypass registry validation/cache invalidation. - Test assemblies still disable parallelization because of global state. - There is still no immutable snapshot registry. - There is still no runtime enforcement of the lifecycle boundary. -## Preconditions for Delivery 02 +## Preconditions for Delivery 03 -- Read `docs/sdd/etapa-6/01-configuration-lifecycle.md` and `docs/sdd/etapa-6/decisions.md`. +- Read `docs/sdd/etapa-6/01-configuration-lifecycle.md`, `docs/sdd/etapa-6/02-mapping-state-encapsulation.md` and `docs/sdd/etapa-6/decisions.md`. - Preserve source/binary compatibility unless a future major-version plan is explicit. - Treat public dictionaries as compatibility debt, not as implementation detail that can be removed. - Use existing tests in `ConfigurationLifecycleTests`, `MappingRegistryTests`, `DiagnosticsApiTests` and `MappingProfileTests` as lifecycle baseline. - Keep Dommel out of scope unless a core change provably requires review. -## Things Delivery 02 Must Not Assume +## Things Delivery 03 Must Not Assume - Do not assume `Initialize(...)` is currently one-shot. - Do not assume runtime mutation can be forbidden in a minor-compatible change. diff --git a/docs/sdd/fluentmap-risk-assessment.md b/docs/sdd/fluentmap-risk-assessment.md index befe587..f79c8cf 100644 --- a/docs/sdd/fluentmap-risk-assessment.md +++ b/docs/sdd/fluentmap-risk-assessment.md @@ -117,6 +117,8 @@ FluentMap still relies on process-wide mapping state and Dapper's global type-ma - `docs/sdd/net10-migration/05-xunit3-migration.md`: preserves `[assembly: CollectionBehavior(DisableTestParallelization = true)]` because tests use global FluentMapper/Dapper/Dommel state. - `src/Dapper.FluentMap/FluentMapper.cs`: static `_registry`, static `_configuration`, public static `EntityMaps` and `TypeConventions`. - `src/Dapper.FluentMap/MappingRegistry.cs`: `SetDapperTypeMap` calls `SqlMapper.SetTypeMap(type, instance)`. +- `docs/sdd/etapa-6/02-mapping-state-encapsulation.md`: adds read-only mapping snapshots while preserving mutable compatibility fields. +- `test/Dapper.FluentMap.Tests/MappingStateEncapsulationTests.cs`: validates official mutation cache/Dapper behavior, read-only snapshots, profile isolation and legacy bypass behavior. - `test/Dapper.FluentMap.Tests/ManualMappingTests.cs` and `test/Dapper.FluentMap.Dommel.Tests/ManualMappingTests.cs`: assembly-level test parallelization disabled. - `docs/sdd/etapa-6/01-configuration-lifecycle.md`: defines the supported lifecycle as startup configuration followed by read-only operation, with runtime mutation allowed only under external quiescence for compatibility. - `test/Dapper.FluentMap.Tests/ConfigurationLifecycleTests.cs`: characterizes repeated additive `Initialize`, serialized runtime registration compatibility, and direct dictionary mutation bypassing Dapper type-map installation. @@ -164,7 +166,9 @@ Related to FM-RISK-002, FM-RISK-005, FM-RISK-013 and the Etapa 5 research item " - `docs/sdd/etapa-1/decisions.md`: keeps public dictionaries and says reducing their mutability is a compatibility-planned change. - `docs/sdd/etapa-3/03-diagnostics-api.md`: keeps the dictionaries public for compatibility. - `src/Dapper.FluentMap/FluentMapper.cs`: exposes `public static readonly ConcurrentDictionary EntityMaps` and `public static readonly ConcurrentDictionary> TypeConventions`. +- `src/Dapper.FluentMap/FluentMapper.cs`: also exposes `GetEntityMaps()` and `GetTypeConventions()` read-only snapshots as the preferred inspection API. - `src/Dapper.FluentMap/MappingRegistry.cs`: validation and invalidation happen only through registry methods, not through arbitrary dictionary mutation. +- `test/Dapper.FluentMap.Tests/MappingStateEncapsulationTests.cs`: characterizes direct map replacement leaving a cached mapping stale, proving the legacy bypass still exists. ### Cenario de impacto @@ -180,11 +184,11 @@ Media. Direct dictionary access is public and historically available, but most d ### Workaround atual -Use `FluentMapper.Initialize`, `AddMap`, `AddMap`, `AddProfile` and convention APIs only. Do not mutate `EntityMaps` or `TypeConventions` directly. +Use `FluentMapper.Initialize`, `AddMap`, `AddMap`, `AddProfile` and convention APIs only. Use `FluentMapper.GetEntityMaps()` and `FluentMapper.GetTypeConventions()` for read-only inspection. Do not mutate `EntityMaps` or `TypeConventions` directly. ### Recomendacao -Document direct mutation as legacy compatibility surface and introduce read-only public views plus explicit migration guidance in a future major version. Consider internal detection of dictionary replacement/mutation only if it can be done without breaking consumers. +Keep direct mutation documented as legacy compatibility surface. Use the new read-only snapshots as the preferred inspection API and plan a future major version that replaces public mutable fields with read-only properties or immutable effective mapping snapshots. Consider internal detection of dictionary replacement/mutation only if it can be done without breaking consumers. ### Relacoes diff --git a/src/Dapper.FluentMap/FluentMapper.cs b/src/Dapper.FluentMap/FluentMapper.cs index a96e61e..8a90e28 100644 --- a/src/Dapper.FluentMap/FluentMapper.cs +++ b/src/Dapper.FluentMap/FluentMapper.cs @@ -24,11 +24,21 @@ public static class FluentMapper /// /// Gets the dictionary containing the entity mapping per entity type. /// + /// + /// This mutable dictionary is preserved for source and binary compatibility. Prefer configuring maps + /// through and use + /// for read-only inspection. + /// public static readonly ConcurrentDictionary EntityMaps = _registry.EntityMaps; /// /// Gets the dictionary containing the conventions per entity type. /// + /// + /// This mutable dictionary is preserved for source and binary compatibility. Prefer configuring conventions + /// through and use + /// for read-only inspection. + /// public static readonly ConcurrentDictionary> TypeConventions = _registry.TypeConventions; internal static MappingRegistry Registry => _registry; @@ -54,6 +64,24 @@ public static void Validate() _registry.ValidateConfiguration(); } + /// + /// Gets a read-only snapshot of the default entity maps currently registered in Dapper.FluentMap. + /// + /// A read-only snapshot of the registered default entity maps. + public static IReadOnlyDictionary GetEntityMaps() + { + return _registry.GetEntityMapsSnapshot(); + } + + /// + /// Gets a read-only snapshot of the type conventions currently registered in Dapper.FluentMap. + /// + /// A read-only snapshot of the registered type conventions. + public static IReadOnlyDictionary> GetTypeConventions() + { + return _registry.GetTypeConventionsSnapshot(); + } + /// /// Explains the effective mapping configuration for the specified entity type. /// diff --git a/src/Dapper.FluentMap/MappingRegistry.cs b/src/Dapper.FluentMap/MappingRegistry.cs index 7552f8b..e12205c 100644 --- a/src/Dapper.FluentMap/MappingRegistry.cs +++ b/src/Dapper.FluentMap/MappingRegistry.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; @@ -34,6 +35,26 @@ internal sealed class MappingRegistry internal int MaterializationPlanCacheEntryCount => _materializationPlanCache.Count; + internal IReadOnlyDictionary GetEntityMapsSnapshot() + { + var snapshot = EntityMaps + .OrderBy(map => map.Key.FullName, StringComparer.Ordinal) + .ToDictionary(map => map.Key, map => map.Value); + + return new ReadOnlyDictionary(snapshot); + } + + internal IReadOnlyDictionary> GetTypeConventionsSnapshot() + { + var snapshot = TypeConventions + .OrderBy(conventions => conventions.Key.FullName, StringComparer.Ordinal) + .ToDictionary( + conventions => conventions.Key, + conventions => (IReadOnlyList)new ReadOnlyCollection(conventions.Value.ToList())); + + return new ReadOnlyDictionary>(snapshot); + } + internal void AddEntityMap(IEntityMap mapper) where TEntity : class { diff --git a/test/Dapper.FluentMap.Tests/MappingStateEncapsulationTests.cs b/test/Dapper.FluentMap.Tests/MappingStateEncapsulationTests.cs new file mode 100644 index 0000000..4fa6e14 --- /dev/null +++ b/test/Dapper.FluentMap.Tests/MappingStateEncapsulationTests.cs @@ -0,0 +1,310 @@ +using System; +using System.Collections.Generic; +using Dapper; +using Dapper.FluentMap.Conventions; +using Dapper.FluentMap.Mapping; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class MappingStateEncapsulationTests + { + [Fact] + public void OfficialMapRegistrationShouldInvalidateCachedMissAndInstallDapperTypeMap() + { + ResetMapper(typeof(OfficialMapEntity)); + + try + { + var miss = FluentMapper.Registry.GetFluentPropertyInfo(typeof(OfficialMapEntity), "official_id"); + + FluentMapper.Initialize(configuration => configuration.AddMap(new OfficialMap())); + + var hit = FluentMapper.Registry.GetFluentPropertyInfo(typeof(OfficialMapEntity), "official_id"); + var dapperMember = SqlMapper.GetTypeMap(typeof(OfficialMapEntity)).GetMember("official_id"); + + Assert.Null(miss); + Assert.Equal(typeof(OfficialMapEntity).GetProperty(nameof(OfficialMapEntity.Id)), hit); + Assert.Equal(nameof(OfficialMapEntity.Id), dapperMember.Property.Name); + Assert.Equal(1, FluentMapper.Registry.CacheEntryCount); + } + finally + { + ResetMapper(typeof(OfficialMapEntity)); + } + } + + [Fact] + public void OfficialConventionRegistrationShouldInvalidateCachedMissAndInstallDapperTypeMap() + { + ResetMapper(typeof(OfficialConventionEntity)); + + try + { + var miss = FluentMapper.Registry.GetFluentPropertyInfo(typeof(OfficialConventionEntity), "cfgId"); + + FluentMapper.Initialize(configuration => configuration + .AddConvention() + .ForEntity()); + + var hit = FluentMapper.Registry.GetFluentPropertyInfo(typeof(OfficialConventionEntity), "cfgId"); + var dapperMember = SqlMapper.GetTypeMap(typeof(OfficialConventionEntity)).GetMember("cfgId"); + + Assert.Null(miss); + Assert.Equal(typeof(OfficialConventionEntity).GetProperty(nameof(OfficialConventionEntity.Id)), hit); + Assert.Equal(nameof(OfficialConventionEntity.Id), dapperMember.Property.Name); + Assert.Equal(1, FluentMapper.Registry.CacheEntryCount); + } + finally + { + ResetMapper(typeof(OfficialConventionEntity)); + } + } + + [Fact] + public void EntityMapsSnapshotShouldBeReadOnlyAndNotTrackLaterRegistrations() + { + ResetMapper(typeof(ReadOnlyFirstEntity), typeof(ReadOnlySecondEntity)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new ReadOnlyFirstMap())); + + var snapshot = FluentMapper.GetEntityMaps(); + + FluentMapper.Initialize(configuration => configuration.AddMap(new ReadOnlySecondMap())); + + Assert.Single(snapshot); + Assert.True(snapshot.ContainsKey(typeof(ReadOnlyFirstEntity))); + Assert.False(snapshot.ContainsKey(typeof(ReadOnlySecondEntity))); + + var mutableSnapshot = Assert.IsAssignableFrom>(snapshot); + Assert.Throws(() => + mutableSnapshot.Add(typeof(ReadOnlySecondEntity), new ReadOnlySecondMap())); + } + finally + { + ResetMapper(typeof(ReadOnlyFirstEntity), typeof(ReadOnlySecondEntity)); + } + } + + [Fact] + public void TypeConventionsSnapshotShouldBeReadOnlyAndNotExposeMutableConventionLists() + { + ResetMapper(typeof(ReadOnlyConventionEntity), typeof(ReadOnlySecondConventionEntity)); + + try + { + FluentMapper.Initialize(configuration => configuration + .AddConvention() + .ForEntity()); + + var snapshot = FluentMapper.GetTypeConventions(); + + FluentMapper.Initialize(configuration => configuration + .AddConvention() + .ForEntity()); + + Assert.Single(snapshot); + Assert.True(snapshot.ContainsKey(typeof(ReadOnlyConventionEntity))); + Assert.False(snapshot.ContainsKey(typeof(ReadOnlySecondConventionEntity))); + + var mutableSnapshot = Assert.IsAssignableFrom>>(snapshot); + Assert.Throws(() => + mutableSnapshot.Add(typeof(ReadOnlySecondConventionEntity), new List())); + + var mutableConventions = Assert.IsAssignableFrom>(snapshot[typeof(ReadOnlyConventionEntity)]); + Assert.Throws(() => + mutableConventions.Add(new SnapshotPrefixConvention())); + } + finally + { + ResetMapper(typeof(ReadOnlyConventionEntity), typeof(ReadOnlySecondConventionEntity)); + } + } + + [Fact] + public void LegacyEntityMapReplacementCanBypassCacheInvalidation() + { + ResetMapper(typeof(LegacyReplacementEntity)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new LegacyIdMap())); + var beforeReplacement = FluentMapper.Registry.GetFluentPropertyInfo(typeof(LegacyReplacementEntity), "shared_column"); + + FluentMapper.EntityMaps[typeof(LegacyReplacementEntity)] = new LegacyNameMap(); + var afterReplacement = FluentMapper.Registry.GetFluentPropertyInfo(typeof(LegacyReplacementEntity), "shared_column"); + + Assert.Equal(typeof(LegacyReplacementEntity).GetProperty(nameof(LegacyReplacementEntity.Id)), beforeReplacement); + Assert.Equal(typeof(LegacyReplacementEntity).GetProperty(nameof(LegacyReplacementEntity.Id)), afterReplacement); + Assert.IsType(FluentMapper.EntityMaps[typeof(LegacyReplacementEntity)]); + } + finally + { + ResetMapper(typeof(LegacyReplacementEntity)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void EntityMapSnapshotShouldPreserveDefaultMapWhileProfilesRemainQueryScoped() + { + ResetMapper(typeof(ProfileSnapshotEntity)); + + try + { + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new ProfileSnapshotDefaultMap()); + configuration.AddProfile(); + }); + + var snapshot = FluentMapper.GetEntityMaps(); + + using (var connection = OpenConnection()) + { + var defaultEntity = connection.QuerySingle( + "SELECT 1 AS default_id;"); + var profileEntity = connection.QueryMappedSingle( + "SELECT 2 AS profile_id;"); + + Assert.IsType(snapshot[typeof(ProfileSnapshotEntity)]); + Assert.Equal(1, defaultEntity.Id); + Assert.Equal(2, profileEntity.Id); + } + } + finally + { + ResetMapper(typeof(ProfileSnapshotEntity)); + } + } + + private static SqliteConnection OpenConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + + private static void ResetMapper(params Type[] types) + { + FluentMapper.Reset(types); + } + + private sealed class SnapshotPrefixConvention : Convention + { + public SnapshotPrefixConvention() + { + Properties() + .Configure(configuration => configuration.HasPrefix("cfg")); + } + } + + private sealed class OfficialMapEntity + { + public int Id { get; set; } + } + + private sealed class OfficialMap : EntityMap + { + public OfficialMap() + { + Map(entity => entity.Id).ToColumn("official_id"); + } + } + + private sealed class OfficialConventionEntity + { + public int Id { get; set; } + } + + private sealed class ReadOnlyFirstEntity + { + public int Id { get; set; } + } + + private sealed class ReadOnlyFirstMap : EntityMap + { + public ReadOnlyFirstMap() + { + Map(entity => entity.Id).ToColumn("first_id"); + } + } + + private sealed class ReadOnlySecondEntity + { + public int Id { get; set; } + } + + private sealed class ReadOnlySecondMap : EntityMap + { + public ReadOnlySecondMap() + { + Map(entity => entity.Id).ToColumn("second_id"); + } + } + + private sealed class ReadOnlyConventionEntity + { + public int Id { get; set; } + } + + private sealed class ReadOnlySecondConventionEntity + { + public int Id { get; set; } + } + + private sealed class LegacyReplacementEntity + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private sealed class LegacyIdMap : EntityMap + { + public LegacyIdMap() + { + Map(entity => entity.Id).ToColumn("shared_column"); + } + } + + private sealed class LegacyNameMap : EntityMap + { + public LegacyNameMap() + { + Map(entity => entity.Name).ToColumn("shared_column"); + } + } + + private sealed class ProfileSnapshot + : IMappingProfile + { + } + + private sealed class ProfileSnapshotEntity + { + public int Id { get; set; } + } + + private sealed class ProfileSnapshotDefaultMap : EntityMap + { + public ProfileSnapshotDefaultMap() + { + Map(entity => entity.Id).ToColumn("default_id"); + } + } + + private sealed class ProfileSnapshotAlternateMap : + EntityMap, + IProfileMap + { + public ProfileSnapshotAlternateMap() + { + Map(entity => entity.Id).ToColumn("profile_id"); + } + } + } +} From dde0ee094f370895111e5b6a671f32b5533ddc42 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sun, 26 Jul 2026 19:22:21 -0300 Subject: [PATCH 17/20] refactor(fluentmap): isolate dapper compatibility internals --- .../03-dapper-compatibility-adapters.md | 277 +++++++++++++++ docs/sdd/etapa-6/README.md | 12 +- docs/sdd/etapa-6/decisions.md | 42 +++ docs/sdd/etapa-6/handoff.md | 171 +++++++-- docs/sdd/fluentmap-risk-assessment.md | 54 +-- .../DapperFluentPropertyTypeMap.cs | 50 +++ .../Compatibility/DapperIgnoredMemberMap.cs | 28 ++ .../Compatibility/DapperPropertyMemberMap.cs | 24 ++ .../Compatibility/DapperTypeHandlerAdapter.cs | 116 ++++++ src/Dapper.FluentMap/MappingRegistry.cs | 12 +- .../NestedMaterializationPlan.cs | 33 +- .../TypeMaps/FluentConventionTypeMap.cs | 16 +- .../TypeMaps/FluentMapTypeMap.cs | 8 +- .../TypeMaps/FluentTypeMap.cs | 8 +- .../TypeMaps/IgnoredPropertyInfo.cs | 28 -- src/Dapper.FluentMap/TypeMaps/MultiTypeMap.cs | 21 +- .../DapperCompatibilityAdapterTests.cs | 329 ++++++++++++++++++ 17 files changed, 1061 insertions(+), 168 deletions(-) create mode 100644 docs/sdd/etapa-6/03-dapper-compatibility-adapters.md create mode 100644 src/Dapper.FluentMap/Compatibility/DapperFluentPropertyTypeMap.cs create mode 100644 src/Dapper.FluentMap/Compatibility/DapperIgnoredMemberMap.cs create mode 100644 src/Dapper.FluentMap/Compatibility/DapperPropertyMemberMap.cs create mode 100644 src/Dapper.FluentMap/Compatibility/DapperTypeHandlerAdapter.cs delete mode 100644 src/Dapper.FluentMap/TypeMaps/IgnoredPropertyInfo.cs create mode 100644 test/Dapper.FluentMap.Tests/DapperCompatibilityAdapterTests.cs diff --git a/docs/sdd/etapa-6/03-dapper-compatibility-adapters.md b/docs/sdd/etapa-6/03-dapper-compatibility-adapters.md new file mode 100644 index 0000000..95d40a4 --- /dev/null +++ b/docs/sdd/etapa-6/03-dapper-compatibility-adapters.md @@ -0,0 +1,277 @@ +# 03 - Dapper Compatibility Adapters + +Status: COMPLETED + +## Specification + +Esta entrega isola os pontos em que o FluentMap dependia de detalhes frageis do Dapper, sem alterar o comportamento publico de mappings e sem reimplementar o Dapper. + +Os riscos tratados sao: + +- `FM-RISK-007`: acesso reflexivo a `SqlMapper.TypeHandlerCache.Parse`; +- `FM-RISK-012`: sentinel `IgnoredPropertyInfo` com membros que lancavam `NotImplementedException`. + +## Current Dapper Integration Points + +O core ainda integra com Dapper por superficies publicas: + +- `SqlMapper.SetTypeMap(type, typeMap)` para instalar o type map default por entidade; +- `SqlMapper.GetTypeMap(type)` em testes e consumidores que inspecionam o estado do Dapper; +- `SqlMapper.ITypeMap` para constructor mapping, member mapping e fallback; +- `SqlMapper.IMemberMap` para expor property/field/parameter ao materializer do Dapper; +- `SqlMapper.HasTypeHandler(type)` para detectar handler registrado; +- `SqlMapper.TypeHandler` para handlers de consumidores. + +A versao fixada no core permanece: + +```text +Dapper 2.1.79 +``` + +Na versao atual nao foi encontrada API publica do Dapper que converta um `object` usando o TypeHandler registrado para um tipo arbitrario. As APIs publicas relacionadas sao registro/reset de handlers, `HasTypeHandler`, type maps, row parsers e parsers baseados em `IDataReader`. + +## Version-Sensitive Areas + +As areas sensiveis a upgrade de Dapper sao: + +- assinatura e comportamento de `SqlMapper.ITypeMap`; +- assinatura e comportamento de `SqlMapper.IMemberMap`; +- comportamento de fallback quando um mapper retorna `null`; +- constructor mapping delegado a `DefaultTypeMap`; +- existencia do nested type `SqlMapper.TypeHandlerCache`; +- existencia do metodo publico static `TypeHandlerCache.Parse(object)`; +- semantica de `SqlMapper.SetTypeMap`, que continua global por processo. + +## TypeHandler Problem + +`QueryMapped*` controla seu proprio loop de `DbDataReader`, portanto nao passa pelo materializer interno do Dapper. Para preservar Value Objects escalares, o materializer precisa respeitar handlers registrados com Dapper. + +O caminho anterior fazia isso dentro de `NestedMaterializationPlan`: + +```text +SqlMapper.HasTypeHandler +typeof(SqlMapper).GetNestedType("TypeHandlerCache`1") +MakeGenericType +GetMethod("Parse") +Expression.Call(Parse) +``` + +Esse acoplamento estava concentrado em uma funcao, mas ainda fazia parte do materializer e podia falhar silenciosamente retornando ao conversor padrao quando a shape interna do Dapper mudasse. + +## Ignored Member Problem + +O caminho anterior usava `IgnoredPropertyInfo : PropertyInfo` para bloquear fallback do Dapper em duas situacoes: + +- propriedade explicitamente ignorada; +- path nested que nao deve ser tratado como propriedade simples pelo `Dapper.Query`. + +O sentinel existia porque `CustomPropertyTypeMap` aceita apenas uma funcao que retorna `PropertyInfo`. Para impedir fallback, era necessario retornar algo nao nulo que o `MultiTypeMap` pudesse reconhecer. + +O problema era a fragilidade: quase todos os membros de `IgnoredPropertyInfo` lancavam `NotImplementedException`. Se Dapper ou outro mapper inspecionasse o `PropertyInfo` antes da interceptacao do FluentMap, a falha escaparia de forma pouco diagnosticavel. + +## Goals + +- Criar uma fronteira interna explicita para detalhes de compatibilidade com Dapper. +- Centralizar reflection residual para TypeHandlers. +- Falhar com diagnostico claro se a shape interna esperada do Dapper deixar de existir. +- Remover o sentinel `PropertyInfo` com `NotImplementedException`. +- Preservar precedencia efetiva: explicito, convention/naming policy, fallback do Dapper. +- Preservar `Dapper.Query` para mappings simples e `QueryMapped*` para materializacao controlada. +- Cobrir os caminhos com testes direcionados. + +## Non-Goals + +- Atualizar Dapper. +- Copiar internals do Dapper para o FluentMap. +- Criar interfaces publicas. +- Substituir `SqlMapper.SetTypeMap`. +- Fazer profiles funcionarem em `Dapper.Query`. +- Implementar materializer gerado. +- Alterar Dommel. + +## Compatibility Boundary + +A fronteira escolhida fica no namespace interno `Dapper.FluentMap.Compatibility`: + +```text +FluentMap materialization/type maps + | + v +internal Dapper compatibility boundary + | + v +Dapper-specific behavior +``` + +Componentes adicionados: + +- `DapperTypeHandlerAdapter`: unico ponto de reflection para `SqlMapper.TypeHandlerCache.Parse(object)`; +- `DapperFluentPropertyTypeMap`: `ITypeMap` interno que resolve `IPropertyMap` sem passar por `CustomPropertyTypeMap`; +- `DapperPropertyMemberMap`: `IMemberMap` seguro para propriedades simples; +- `DapperIgnoredMemberMap`: `IMemberMap` seguro para ignored/nested e reconhecido pelo `MultiTypeMap`. + +## Proposed Design + +### TypeHandler + +`NestedMaterializationPlan` passa a delegar a decisao e a construcao do conversor para `DapperTypeHandlerAdapter`. + +O adapter: + +- usa `SqlMapper.HasTypeHandler` como superficie publica de deteccao; +- usa reflection residual apenas para localizar `TypeHandlerCache.Parse(object)`; +- considera `Nullable` separando tipo declarado e tipo do handler; +- retorna `null` para `DBNull` quando o destino declarado aceita null; +- lanca `FluentMapConfigurationException` quando a shape esperada do Dapper nao existe. + +### Ignored Member + +`FluentMapTypeMap`, `FluentConventionTypeMap` e o type map interno nao usam mais `CustomPropertyTypeMap` para propriedades FluentMap. Eles usam `DapperFluentPropertyTypeMap`, que pode retornar diretamente um `IMemberMap`. + +Quando um mapping e ignored ou nested: + +```text +DapperFluentPropertyTypeMap.GetMember(column) + -> DapperIgnoredMemberMap + -> MultiTypeMap reconhece o marker + -> retorna null sem consultar DefaultTypeMap +``` + +Assim o fallback do Dapper continua bloqueado, mas nenhum `PropertyInfo` falso ou lancador e exposto. + +## Alternatives Rejected + +### Keep IgnoredPropertyInfo + +Rejeitada. Preservaria comportamento, mas manteria o risco de `NotImplementedException` se o sentinel fosse inspecionado. + +### Return null for ignored/nested directly + +Rejeitada. Isso permitiria que `MultiTypeMap` continuasse para `DefaultTypeMap`, fazendo propriedades ignoradas ou paths nested com mesmo nome de coluna serem materializados pelo Dapper. + +### Copy Dapper TypeHandler internals + +Rejeitada. A entrega e sobre boundary de compatibilidade, nao fork ou copia de implementacao. + +### Upgrade Dapper + +Rejeitada nesta entrega. Nao ha specification de dependency upgrade, e a versao `2.1.79` permanece a referencia validada. + +## Failure Behavior + +Se o TypeHandler estiver registrado, mas o adapter nao conseguir resolver `SqlMapper.TypeHandlerCache.Parse(object)`, o FluentMap deve lancar `FluentMapConfigurationException` com: + +- tipo de destino; +- mencao explicita ao boundary de TypeHandler; +- orientacao para revisar compatibilidade antes de atualizar Dapper. + +Falha diagnosticavel foi escolhida em vez de fallback silencioso, porque fallback para `Convert.ChangeType` pode materializar valor errado ou mascarar uma quebra de upgrade. + +## Acceptance Criteria + +- `DapperTypeHandlerAdapter` centraliza reflection para TypeHandlers. +- `NestedMaterializationPlan` nao chama `GetNestedType`, `MakeGenericType` ou `GetMethod` para TypeHandler. +- TypeHandler registrado e usado por `QueryMapped*`. +- `Nullable` com handler registrado preserva `null` para `DBNull`. +- Sem handler, conversao padrao existente continua funcionando. +- Falha de shape interna do Dapper e diagnosticavel. +- `IgnoredPropertyInfo` e removido. +- Ignored root property bloqueia fallback do Dapper. +- Ignored nested path bloqueia fallback para propriedade raiz homonima. +- Fallback default do Dapper continua funcionando para colunas nao configuradas. +- Testes de Etapa 5 continuam passando. + +## Residual Risks + +- `FM-RISK-007` permanece `MITIGATED`, nao `RESOLVED`: ainda existe reflection para `SqlMapper.TypeHandlerCache.Parse(object)`, mas ela esta isolada e coberta por testes. +- `SqlMapper.SetTypeMap` permanece estado global do Dapper. +- `QueryMapped*` continua runtime/reflection/dynamic-code based. +- Upgrades futuros de Dapper ainda exigem checklist especifico para `ITypeMap`, `IMemberMap`, constructor mapping e TypeHandlers. + +## Implementation + +Arquivos adicionados: + +- `src/Dapper.FluentMap/Compatibility/DapperTypeHandlerAdapter.cs`; +- `src/Dapper.FluentMap/Compatibility/DapperFluentPropertyTypeMap.cs`; +- `src/Dapper.FluentMap/Compatibility/DapperPropertyMemberMap.cs`; +- `src/Dapper.FluentMap/Compatibility/DapperIgnoredMemberMap.cs`; +- `test/Dapper.FluentMap.Tests/DapperCompatibilityAdapterTests.cs`. + +Arquivos alterados: + +- `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs`; +- `src/Dapper.FluentMap/TypeMaps/FluentTypeMap.cs`; +- `src/Dapper.FluentMap/TypeMaps/FluentMapTypeMap.cs`; +- `src/Dapper.FluentMap/TypeMaps/FluentConventionTypeMap.cs`; +- `src/Dapper.FluentMap/TypeMaps/MultiTypeMap.cs`; +- `src/Dapper.FluentMap/MappingRegistry.cs`. + +Arquivo removido: + +- `src/Dapper.FluentMap/TypeMaps/IgnoredPropertyInfo.cs`. + +## Validation Results + +Environment: + +- SDK: `10.0.302` +- test runner detected: VSTest with xUnit v3 +- core target: `netstandard2.0` +- test target: `net10.0` +- Dapper: `2.1.79` + +Localized validation: + +```text +dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Debug --filter "FullyQualifiedName~DapperCompatibilityAdapterTests" +``` + +Result: + +- success; +- 8 tests passed. + +Related validation: + +```text +dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Debug --filter "FullyQualifiedName~DapperCompatibilityAdapterTests|FullyQualifiedName~ValueObjectMaterializationTests|FullyQualifiedName~NestedMaterializationSpikeTests|FullyQualifiedName~NestedObjectMaterializationTests|FullyQualifiedName~ConstructorMappingTests|FullyQualifiedName~DapperIntegrationTests|FullyQualifiedName~MappingProfileTests" +dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --no-build --filter "FullyQualifiedName~DapperCompatibilityAdapterTests|FullyQualifiedName~ValueObjectMaterializationTests|FullyQualifiedName~NestedMaterializationSpikeTests|FullyQualifiedName~NestedObjectMaterializationTests|FullyQualifiedName~ConstructorMappingTests|FullyQualifiedName~DapperIntegrationTests|FullyQualifiedName~MappingProfileTests" +``` + +Results: + +- Debug related tests: success, 79 tests passed; +- Release related tests: success, 79 tests passed. + +Mandatory validation: + +```text +dotnet restore .\Dapper.FluentMap.sln +dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore +dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build +dotnet pack .\Dapper.FluentMap.sln --configuration Release --no-build --output .\artifacts\packages +``` + +Results: + +- restore: success; +- build: success, 0 warnings, 0 errors; +- tests: success, 229 total tests passed: + - core: 198; + - Dommel: 7; + - analyzers: 9; + - generators: 14; + - generated-registration integration: 1; +- pack: success: + - `Dapper.FluentMap.2.0.0.nupkg`; + - `Dapper.FluentMap.Dommel.2.0.0.nupkg`; + - `Dapper.FluentMap.Analyzers.2.0.0.nupkg`; + - `Dapper.FluentMap.Generators.2.0.0.nupkg`. + +Known pack warnings: + +- `NU5125` for legacy `PackageLicenseUrl` in core and Dommel; +- NuGet README recommendation for core and Dommel. + +These warnings are pre-existing package metadata debt tracked outside this delivery. diff --git a/docs/sdd/etapa-6/README.md b/docs/sdd/etapa-6/README.md index 57c571d..dbd80ca 100644 --- a/docs/sdd/etapa-6/README.md +++ b/docs/sdd/etapa-6/README.md @@ -12,15 +12,15 @@ Esta etapa preserva a compatibilidade publica existente do core `Dapper.FluentMa |---|---|---|---| | 01 | Configuration Lifecycle | COMPLETED | Lifecycle suportado e mutacoes de runtime formalizados. | | 02 | Mapping State Encapsulation | COMPLETED | Snapshots read-only adicionados e superficie mutavel legada documentada. | -| 03 | Dapper Compatibility Adapters | NEXT | Isolar contratos de compatibilidade com Dapper. | -| 04 | Generated Materializer Spike | PENDING | Investigar materializer gerado para `DbDataReader`. | +| 03 | Dapper Compatibility Adapters | COMPLETED | Compatibility boundary interno adicionado; TypeHandler reflection isolada; ignored sentinel removido. | +| 04 | Generated Materializer Spike | NEXT | Investigar materializer gerado para `DbDataReader`. | ## Delivery List 01 Configuration Lifecycle -> COMPLETED 02 Mapping State Encapsulation -> COMPLETED -03 Dapper Compatibility Adapters -> NEXT -04 Generated Materializer Spike -> PENDING +03 Dapper Compatibility Adapters -> COMPLETED +04 Generated Materializer Spike -> NEXT ## Sources Of Truth @@ -35,6 +35,6 @@ Esta etapa preserva a compatibilidade publica existente do core `Dapper.FluentMa ## Current Focus -Delivery 01 defined the supported configuration lifecycle without removing public APIs and without introducing premature runtime sealing. +Delivery 03 isolated Dapper compatibility details behind internal adapters. Residual TypeHandler reflection remains, but only in `DapperTypeHandlerAdapter`; `IgnoredPropertyInfo` was removed. -Delivery 02 should use this lifecycle contract as the boundary for planning state encapsulation. +Delivery 04 should investigate a generated `DbDataReader` materializer while preserving lifecycle, profiles, `MemberPath`, TypeHandler behavior and ignored mapping semantics. diff --git a/docs/sdd/etapa-6/decisions.md b/docs/sdd/etapa-6/decisions.md index 0b003a2..5988a8d 100644 --- a/docs/sdd/etapa-6/decisions.md +++ b/docs/sdd/etapa-6/decisions.md @@ -83,3 +83,45 @@ Dapper integration ``` Mutacoes diretas nos campos legados continuam possiveis, podem ignorar invariantes e exigem migracao futura de major version para serem removidas ou substituidas por propriedades read-only. + +## E6-D005 - Dapper Compatibility Boundary + +Entrega 03 cria uma fronteira interna explicita para detalhes de compatibilidade com Dapper no namespace `Dapper.FluentMap.Compatibility`. + +Essa fronteira concentra: + +- invocacao de TypeHandlers registrados no Dapper por `DapperTypeHandlerAdapter`; +- exposicao de property mappings ao Dapper por `DapperFluentPropertyTypeMap`; +- `IMemberMap` seguro para propriedades simples por `DapperPropertyMemberMap`; +- marker seguro para ignored/nested por `DapperIgnoredMemberMap`. + +Nenhuma API publica foi adicionada. O objetivo e manter detalhes Dapper-specific fora do materializer e reduzir o numero de pontos onde uma mudanca interna do Dapper pode afetar o FluentMap. + +## E6-D006 - Residual TypeHandler Reflection + +Nao foi encontrada no Dapper `2.1.79` uma API publica que converta um `object` usando o TypeHandler registrado para um tipo arbitrario. + +Por isso, a reflection residual para `SqlMapper.TypeHandlerCache.Parse(object)` permanece, mas fica isolada em `DapperTypeHandlerAdapter`. Se a shape esperada nao existir em uma versao futura do Dapper, o FluentMap deve falhar com `FluentMapConfigurationException` diagnosticavel em vez de cair silenciosamente para `Convert.ChangeType`. + +Esse risco fica `MITIGATED`, nao `RESOLVED`, ate existir alternativa publica suportada pelo Dapper ou ate o FluentMap deixar de precisar invocar handlers no materializer runtime. + +## E6-D007 - Ignored Mapping Without Throwing PropertyInfo Sentinel + +Entrega 03 remove `IgnoredPropertyInfo`. + +Mappings ignored e nested deixam de passar por `CustomPropertyTypeMap` para retornar um `PropertyInfo` falso. O caminho atual retorna um `DapperIgnoredMemberMap`, que implementa `SqlMapper.IMemberMap` com propriedades seguras e nulas. `MultiTypeMap` reconhece esse marker e retorna `null` sem continuar para `DefaultTypeMap`, preservando o bloqueio de fallback. + +Com isso, `FM-RISK-012` fica `RESOLVED`: nao ha mais sentinel `PropertyInfo` interno com membros que lancam `NotImplementedException`. + +## E6-D008 - Dapper Upgrade Checklist + +Qualquer upgrade futuro de Dapper deve revisar explicitamente: + +- `SqlMapper.ITypeMap`; +- `SqlMapper.IMemberMap`; +- `DefaultTypeMap` constructor/member behavior; +- `SqlMapper.SetTypeMap` global state; +- `SqlMapper.HasTypeHandler`; +- `SqlMapper.TypeHandlerCache.Parse(object)`; +- comportamento de fallback quando um mapper retorna `null`; +- testes `DapperCompatibilityAdapterTests`, `ValueObjectMaterializationTests`, `ConstructorMappingTests`, `NestedMaterializationSpikeTests`, `DapperIntegrationTests` e Dommel. diff --git a/docs/sdd/etapa-6/handoff.md b/docs/sdd/etapa-6/handoff.md index eb78082..0ffd6a8 100644 --- a/docs/sdd/etapa-6/handoff.md +++ b/docs/sdd/etapa-6/handoff.md @@ -2,7 +2,7 @@ ## Last Completed Delivery -02 — Mapping State Encapsulation +03 - Dapper Compatibility Adapters ## Current Architecture @@ -21,7 +21,7 @@ Delivery 02 added read-only snapshot APIs: These APIs return snapshot collections for inspection and do not expose the live `ConcurrentDictionary` instances or mutable convention lists. -The supported lifecycle is now documented as: +The supported lifecycle is: ```text Configuration Phase @@ -34,14 +34,125 @@ Configuration should happen during startup or before first use of the affected t Profiles remain query-scoped through `QueryMapped()` and do not swap the Dapper global type map. +## Dapper Compatibility Boundary + +Delivery 03 added an internal compatibility boundary in `Dapper.FluentMap.Compatibility`: + +- `DapperTypeHandlerAdapter`: centralizes residual reflection into `SqlMapper.TypeHandlerCache.Parse(object)`; +- `DapperFluentPropertyTypeMap`: exposes FluentMap property mappings to Dapper without using `CustomPropertyTypeMap`; +- `DapperPropertyMemberMap`: safe `SqlMapper.IMemberMap` for simple property mappings; +- `DapperIgnoredMemberMap`: safe `SqlMapper.IMemberMap` marker for ignored mappings and FluentMap-controlled nested paths. + +The intended shape is: + +```text +FluentMap materialization/type maps + | + v +internal Dapper compatibility boundary + | + v +Dapper-specific behavior +``` + +`NestedMaterializationPlan` should not grow new direct reflection into Dapper internals. Future Dapper-specific workarounds should go through this compatibility boundary or a similarly explicit adapter. + +## Remaining Reflection Into Dapper Internals + +Residual reflection remains only for TypeHandler invocation in `DapperTypeHandlerAdapter`: + +```text +SqlMapper.TypeHandlerCache.Parse(object) +``` + +Dapper `2.1.79` exposes `SqlMapper.HasTypeHandler(type)` publicly, but no public API was found to convert a single `object` through the registered handler for an arbitrary target type. Because of that, `FM-RISK-007` is `MITIGATED`, not `RESOLVED`. + +If the expected Dapper shape is missing, the adapter throws `FluentMapConfigurationException` with an upgrade-oriented diagnostic instead of silently falling back to `Convert.ChangeType`. + +## Ignored Mapping Strategy + +`IgnoredPropertyInfo` was removed. + +Ignored root mappings and FluentMap-controlled nested paths now flow as: + +```text +DapperFluentPropertyTypeMap.GetMember(column) + | + v +DapperIgnoredMemberMap + | + v +MultiTypeMap returns null without consulting DefaultTypeMap +``` + +This preserves the existing behavior that ignored/nested FluentMap mappings block Dapper fallback for that column, while removing the previous `PropertyInfo` sentinel whose members threw `NotImplementedException`. + +`FM-RISK-012` is `RESOLVED`. + +## Dapper Upgrade Checklist + +Before upgrading Dapper, review: + +- `SqlMapper.ITypeMap`; +- `SqlMapper.IMemberMap`; +- `DefaultTypeMap` constructor and member behavior; +- `SqlMapper.SetTypeMap` global behavior; +- `SqlMapper.HasTypeHandler`; +- `SqlMapper.TypeHandlerCache.Parse(object)`; +- fallback behavior when a mapper returns `null`; +- `DapperCompatibilityAdapterTests`; +- `ValueObjectMaterializationTests`; +- `NestedMaterializationSpikeTests`; +- `NestedObjectMaterializationTests`; +- `ConstructorMappingTests`; +- `DapperIntegrationTests`; +- Dommel tests. + +Do not update Dapper merely to test the adapter. Treat dependency upgrade as its own specification. + +## Materialization Architecture Relevant to Delivery 04 + +`QueryMapped*` still uses runtime `DbDataReader` materialization with cached `NestedMaterializationPlan`. + +The plan remains responsible for: + +- resolving the effective map by entity, optional profile and column shape; +- preserving full `MemberPath` identity; +- deciding nested/null subtree behavior; +- invoking constructors for immutable objects and Value Objects; +- applying Dapper TypeHandlers for scalar mapped properties through `DapperTypeHandlerAdapter`; +- falling back to local conversion when no handler is registered. + +`Dapper.Query` remains Dapper-owned and should continue to use the default type map installed by `SqlMapper.SetTypeMap`. + +## Constraints the Generated Materializer Spike Must Preserve + +Delivery 04 must preserve: + +- explicit mapping before convention/naming policy before Dapper default; +- query-scoped profiles without temporary `SqlMapper.SetTypeMap` mutation; +- `MemberPath` identity for same-terminal nested paths; +- ignored mappings blocking Dapper/default fallback for their configured columns; +- TypeHandler behavior for scalar Value Object properties; +- nullable TypeHandler null semantics; +- diagnostic failure when Dapper compatibility internals are invalid; +- configuration lifecycle from Delivery 01; +- read-only snapshot behavior from Delivery 02; +- no public API additions unless the generated materializer specification explicitly justifies them; +- no Dommel redesign unless core changes require it. + ## Decisions That Must Be Preserved - E6-D001 - Configuration lifecycle is startup configuration followed by read-only operation. - E6-D002 - Delivery 01 chose Documentation Contract Only; no `Freeze()`, no sealing API and no runtime enforcement. - E6-D003 - Profiles remain query-scoped and must not be implemented by temporary `SqlMapper.SetTypeMap` mutation. - E6-D004 - Mapping state read-only snapshots are the minor-compatible encapsulation path; mutable public fields remain legacy compatibility surface. +- E6-D005 - Dapper compatibility details are isolated behind internal adapters. +- E6-D006 - Residual TypeHandler reflection remains isolated and diagnostic. +- E6-D007 - Ignored mappings use safe `IMemberMap` markers, not throwing `PropertyInfo` sentinels. +- E6-D008 - Dapper upgrades require targeted compatibility review. -## Mapping State After Delivery 02 +## Mapping State After Delivery 03 Official mutation paths still go through `FluentMapConfiguration` and `MappingRegistry`: @@ -89,69 +200,60 @@ Read-only inspection should use `GetEntityMaps()` and `GetTypeConventions()`. Th - Official convention/naming-policy registration invalidates caches for the entity type and reinstalls the Dapper type map. - Direct mutation of legacy public dictionaries does not invalidate caches. -## Decisions Delivery 03 Must Preserve - -- Do not make profiles visible to `Dapper.Query()` by mutating global Dapper type maps per operation. -- Do not duplicate validation or cache invalidation outside `MappingRegistry`. -- Prefer adapters around Dapper compatibility boundaries over widening public mutable state. -- Keep `GetEntityMaps()` and `GetTypeConventions()` as read-only inspection snapshots. -- Treat removal or type changes of `EntityMaps`/`TypeConventions` as future major-version work. - ## Remaining Dapper-Specific Technical Debt - `SqlMapper.SetTypeMap` remains process-global state. - Dommel still reads public legacy mapping dictionaries directly. - Profiles are not supported through `Dapper.Query()` or Dapper multi-mapping. - The runtime materializer still uses reflection/dynamic-code paths for `QueryMapped*`. -- Dapper compatibility details around type maps, constructor mapping and handlers remain candidates for Delivery 03. +- Residual TypeHandler invocation still reflects into `SqlMapper.TypeHandlerCache.Parse(object)`, isolated by `DapperTypeHandlerAdapter`. -## Files Changed In Delivery 02 +## Files Changed In Delivery 03 - `README.md` - `docs/sdd/fluentmap-risk-assessment.md` - `docs/sdd/etapa-6/README.md` - `docs/sdd/etapa-6/decisions.md` - `docs/sdd/etapa-6/handoff.md` -- `docs/sdd/etapa-6/02-mapping-state-encapsulation.md` -- `src/Dapper.FluentMap/FluentMapper.cs` +- `docs/sdd/etapa-6/03-dapper-compatibility-adapters.md` +- `src/Dapper.FluentMap/Compatibility/DapperTypeHandlerAdapter.cs` +- `src/Dapper.FluentMap/Compatibility/DapperFluentPropertyTypeMap.cs` +- `src/Dapper.FluentMap/Compatibility/DapperPropertyMemberMap.cs` +- `src/Dapper.FluentMap/Compatibility/DapperIgnoredMemberMap.cs` +- `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs` - `src/Dapper.FluentMap/MappingRegistry.cs` -- `test/Dapper.FluentMap.Tests/MappingStateEncapsulationTests.cs` +- `src/Dapper.FluentMap/TypeMaps/FluentTypeMap.cs` +- `src/Dapper.FluentMap/TypeMaps/FluentMapTypeMap.cs` +- `src/Dapper.FluentMap/TypeMaps/FluentConventionTypeMap.cs` +- `src/Dapper.FluentMap/TypeMaps/MultiTypeMap.cs` +- `src/Dapper.FluentMap/TypeMaps/IgnoredPropertyInfo.cs` removed +- `test/Dapper.FluentMap.Tests/DapperCompatibilityAdapterTests.cs` ## Public API Impact -New public API was added: - -- `FluentMapper.GetEntityMaps()`; -- `FluentMapper.GetTypeConventions()`. - -No public API was removed, renamed or marked obsolete. - -The public documentation now states: +Delivery 03 added no public API and removed no public API. -- configure during startup; -- optionally call `FluentMapper.Validate()`; -- treat configuration as read-only once queries begin; -- runtime mutation after queries is compatibility-only and requires external quiescence; -- direct dictionary mutation is legacy and can bypass validation, cache invalidation and Dapper type-map installation. -- read-only inspection should use snapshot APIs. +The internal `IgnoredPropertyInfo` implementation detail was removed. FluentMap public behavior for ignored mappings, nested paths, Dapper fallback, TypeHandlers and profiles is preserved. ## Remaining Risks - FM-RISK-001 remains mitigated, not resolved: global FluentMap/Dapper state still exists. - FM-RISK-002 remains open with mitigation: read-only snapshots now exist, but public mutable dictionaries can still bypass registry validation/cache invalidation. +- FM-RISK-007 remains mitigated, not resolved: TypeHandler invocation still reflects into Dapper internals, but only through `DapperTypeHandlerAdapter`. +- FM-RISK-012 is resolved: `IgnoredPropertyInfo` no longer exists. - Test assemblies still disable parallelization because of global state. - There is still no immutable snapshot registry. - There is still no runtime enforcement of the lifecycle boundary. -## Preconditions for Delivery 03 +## Preconditions for Delivery 04 -- Read `docs/sdd/etapa-6/01-configuration-lifecycle.md`, `docs/sdd/etapa-6/02-mapping-state-encapsulation.md` and `docs/sdd/etapa-6/decisions.md`. +- Read `docs/sdd/etapa-6/01-configuration-lifecycle.md`, `docs/sdd/etapa-6/02-mapping-state-encapsulation.md`, `docs/sdd/etapa-6/03-dapper-compatibility-adapters.md` and `docs/sdd/etapa-6/decisions.md`. - Preserve source/binary compatibility unless a future major-version plan is explicit. - Treat public dictionaries as compatibility debt, not as implementation detail that can be removed. -- Use existing tests in `ConfigurationLifecycleTests`, `MappingRegistryTests`, `DiagnosticsApiTests` and `MappingProfileTests` as lifecycle baseline. +- Use existing tests in `ConfigurationLifecycleTests`, `MappingRegistryTests`, `DiagnosticsApiTests`, `MappingProfileTests`, `DapperCompatibilityAdapterTests`, `ValueObjectMaterializationTests` and `DapperIntegrationTests` as baseline. - Keep Dommel out of scope unless a core change provably requires review. -## Things Delivery 03 Must Not Assume +## Things Delivery 04 Must Not Assume - Do not assume `Initialize(...)` is currently one-shot. - Do not assume runtime mutation can be forbidden in a minor-compatible change. @@ -159,3 +261,4 @@ The public documentation now states: - Do not assume profiles are visible to `Dapper.Query()` or Dommel. - Do not assume test parallelization can be re-enabled before global state is encapsulated or isolated. - Do not add a freeze/seal API without a compatibility and migration decision. +- Do not add new direct reflection into Dapper internals outside the compatibility boundary. diff --git a/docs/sdd/fluentmap-risk-assessment.md b/docs/sdd/fluentmap-risk-assessment.md index f79c8cf..16d607d 100644 --- a/docs/sdd/fluentmap-risk-assessment.md +++ b/docs/sdd/fluentmap-risk-assessment.md @@ -54,7 +54,8 @@ Current risk count: - Medium: 10 - Low: 5 - Open: 9 -- Mitigated: 7 +- Mitigated: 6 +- Resolved: 1 - Unknown: 2 Overall, FluentMap is not in a critical architectural state. The main runtime contract is well protected by SDD decisions, integration tests, fail-fast validation, `MemberPath`, cache keys, and query-scoped profiles. The largest remaining risks come from intentionally preserved global/static compatibility surfaces, trimming/AOT constraints, and the fact that the new materializer is runtime/reflection-based rather than generated. @@ -83,7 +84,7 @@ Overall, FluentMap is not in a critical architectural state. The main runtime co | FM-RISK-009 | Mapping profiles do not apply to `Dapper.Query` or Dapper multi-mapping | MEDIUM | Medium | Profiles, API Design | Etapa 5 / Entrega 04 | OPEN | | FM-RISK-010 | Legacy `ApplyMapsFromAssemblies` keeps older reflection/discovery behavior | MEDIUM | Low | Reflection, Maintainability | Etapa 2 / Entrega 02; Etapa 3 / Entrega 01 | MITIGATED | | FM-RISK-011 | Constructor overload ambiguity and optional parameters remain delegated to Dapper | MEDIUM | Low | Materialization, Correctness | Etapa 3 / Entrega 02 | OPEN | -| FM-RISK-012 | `IgnoredPropertyInfo` sentinel throws `NotImplementedException` if inspected outside the intended path | MEDIUM | Low | Correctness, Maintainability | Etapa 2 / Entrega 02 | MITIGATED | +| FM-RISK-012 | Throwing `IgnoredPropertyInfo` sentinel was removed from ignored/nested mapping paths | MEDIUM | Low | Correctness, Maintainability | Etapa 2 / Entrega 02; Etapa 6 / Entrega 03 | RESOLVED | | FM-RISK-013 | Dommel behavior for profiles/nested materialization is intentionally unreviewed | MEDIUM | Low | Dommel, Extensibility | Etapa 5 / Entrega 04 | UNKNOWN | | FM-RISK-014 | Analyzer and generator coverage is intentionally partial | LOW | High | Developer Experience, Testing | Etapa 4 / Entrega 01-03 | MITIGATED | | FM-RISK-015 | Async `QueryMapped*` overloads are asymmetric: profile async exists, default async does not | LOW | Medium | API Design | Etapa 5 / Entrega 04 | OPEN | @@ -384,23 +385,25 @@ Related to FM-RISK-004, FM-RISK-007 and Etapa 5 P2 factory-method follow-up. **Categoria:** Compatibility, Reflection, Value Objects, Maintainability **Origem:** Etapa 5 / Entrega 03 **Detectado em:** implementation review -**Componentes afetados:** `NestedMaterializationPlan.CreateTypeHandlerConverter` +**Componentes afetados:** `DapperTypeHandlerAdapter`, `NestedMaterializationPlan` ### Descricao -The runtime materializer detects a Dapper type handler with `SqlMapper.HasTypeHandler`, but then calls Dapper's nested `TypeHandlerCache.Parse` using reflection. This couples the implementation to a Dapper type/cache shape that may change across Dapper versions. +The runtime materializer detects a Dapper type handler with `SqlMapper.HasTypeHandler`. Dapper `2.1.79` does not expose a public API to convert one arbitrary `object` through the registered handler, so FluentMap still calls Dapper's nested `TypeHandlerCache.Parse` using reflection. The reflection is now isolated behind an internal compatibility adapter instead of living in the materialization plan. ### Evidencias - `docs/sdd/etapa-5/01-nested-materialization-spike.md`: records that conversions should respect TypeHandlers without copying Dapper internals. - `docs/sdd/etapa-5/03-value-objects.md`: states TypeHandler support is preserved for scalar Value Object properties. -- `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs`: `CreateTypeHandlerConverter` uses `typeof(SqlMapper).GetNestedType("TypeHandlerCache`1", BindingFlags.Public | BindingFlags.NonPublic)`, `MakeGenericType` and reflection to call `Parse`. -- `test/Dapper.FluentMap.Tests/ValueObjectMaterializationTests.cs`: verifies `QueryMappedShouldUseDapperTypeHandlerForScalarValueObjectProperty`. +- `src/Dapper.FluentMap/Compatibility/DapperTypeHandlerAdapter.cs`: centralizes `TypeHandlerCache.Parse(object)` reflection and fails with `FluentMapConfigurationException` if the expected Dapper shape is missing. +- `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs`: delegates TypeHandler detection/invocation to `DapperTypeHandlerAdapter`. +- `test/Dapper.FluentMap.Tests/DapperCompatibilityAdapterTests.cs`: verifies registered handler conversion, nullable handler null semantics, no-handler fallback and diagnostic failure when the compatibility boundary cannot resolve the Dapper cache shape. +- `test/Dapper.FluentMap.Tests/ValueObjectMaterializationTests.cs`: continues to verify `QueryMappedShouldUseDapperTypeHandlerForScalarValueObjectProperty`. - `src/Dapper.FluentMap/Dapper.FluentMap.csproj`: Dapper is pinned to `2.1.79`, reducing immediate drift. ### Cenario de impacto -A future Dapper update removes, renames or changes `TypeHandlerCache.Parse`. FluentMap still sees `HasTypeHandler == true`, but cannot call the handler through this reflective path and falls back to conversion behavior that may not support the Value Object. +A future Dapper update removes, renames or changes `TypeHandlerCache.Parse`. FluentMap still sees `HasTypeHandler == true`, but the adapter cannot call the handler through this reflective path and throws a diagnostic compatibility exception during plan creation. ### Impacto @@ -412,11 +415,11 @@ Baixa. The current dependency is pinned and covered by tests, but the risk rises ### Workaround atual -Keep Dapper upgrade tasks isolated and run `ValueObjectMaterializationTests`. Consumers can use Dapper `Query` for scalar TypeHandler paths outside `QueryMapped*`. +Keep Dapper upgrade tasks isolated and run `DapperCompatibilityAdapterTests` plus `ValueObjectMaterializationTests`. Consumers can use Dapper `Query` for scalar TypeHandler paths outside `QueryMapped*`. ### Recomendacao -Investigate a public Dapper-supported handler invocation path. If none exists, wrap this behavior behind a small compatibility adapter with targeted tests and explicit Dapper-version review notes. +Investigate a public Dapper-supported handler invocation path during future Dapper upgrades. If none exists, keep the adapter small, fail diagnosticably and do not spread reflection into materialization code. ### Relacoes @@ -599,49 +602,51 @@ Do not reimplement Dapper constructor selection casually. If demand appears, add Related to FM-RISK-004 and FM-RISK-006. -## FM-RISK-012 - `IgnoredPropertyInfo` sentinel throws `NotImplementedException` if inspected outside the intended path +## FM-RISK-012 - Throwing `IgnoredPropertyInfo` sentinel was removed from ignored/nested mapping paths **Severidade:** MEDIUM -**Status:** MITIGATED +**Status:** RESOLVED **Categoria:** Correctness, Maintainability, Technical Debt -**Origem:** Etapa 2 / Entrega 02 +**Origem:** Etapa 2 / Entrega 02; Etapa 6 / Entrega 03 **Detectado em:** implementation review -**Componentes afetados:** `IgnoredPropertyInfo`, `MultiTypeMap`, `MappingRegistry.MappingCacheEntry` +**Componentes afetados:** `DapperIgnoredMemberMap`, `DapperFluentPropertyTypeMap`, `MultiTypeMap` ### Descricao -Ignored and nested mappings use an internal `IgnoredPropertyInfo` sentinel to prevent Dapper fallback. The sentinel overrides many `PropertyInfo` members by throwing `NotImplementedException`. The current `MultiTypeMap` recognizes the sentinel and returns `null`, but misuse or a Dapper behavior change could inspect it. +Ignored and nested mappings previously used an internal `IgnoredPropertyInfo` sentinel to prevent Dapper fallback. The sentinel overrode many `PropertyInfo` members by throwing `NotImplementedException`. Etapa 6 / Entrega 03 removed that sentinel and replaced it with an explicit internal `IMemberMap` marker that has safe null members. ### Evidencias - `docs/sdd/etapa-2/02-configuration-validation.md`: catalogs `IgnoredPropertyInfo` throwing `NotImplementedException` as partially detectable and outside the delivery scope. -- `src/Dapper.FluentMap/TypeMaps/IgnoredPropertyInfo.cs`: most members throw `NotImplementedException`. -- `src/Dapper.FluentMap/MappingRegistry.cs`: `MappingCacheEntry` assigns `IgnoredPropertyInfo` for ignored maps and nested maps when returning a `PropertyInfo` to Dapper's simple type-map pipeline. -- `src/Dapper.FluentMap/TypeMaps/MultiTypeMap.cs`: explicitly checks `result is IgnoredPropertyInfo || result.Property is IgnoredPropertyInfo` and returns `null`. +- `src/Dapper.FluentMap/TypeMaps/IgnoredPropertyInfo.cs`: removed. +- `src/Dapper.FluentMap/Compatibility/DapperFluentPropertyTypeMap.cs`: returns `DapperIgnoredMemberMap` for ignored mappings and FluentMap-controlled nested paths. +- `src/Dapper.FluentMap/Compatibility/DapperIgnoredMemberMap.cs`: implements `SqlMapper.IMemberMap` without throwing `PropertyInfo` members. +- `src/Dapper.FluentMap/TypeMaps/MultiTypeMap.cs`: recognizes `DapperIgnoredMemberMap` and returns `null` without falling back to `DefaultTypeMap`. +- `test/Dapper.FluentMap.Tests/DapperCompatibilityAdapterTests.cs`: verifies ignored root mapping, ignored nested path, Dapper fallback for unrelated members and direct type-map access without `NotImplementedException`. ### Cenario de impacto -A future Dapper version inspects more of the returned `PropertyInfo` before FluentMap can intercept it, or an external mapper uses the sentinel unexpectedly. A `NotImplementedException` escapes from a materialization path. +Previously, a future Dapper version could inspect more of the returned `PropertyInfo` before FluentMap intercepted it. That path no longer exists because ignored/nested markers no longer expose a throwing `PropertyInfo`. ### Impacto -Unexpected runtime failure in ignored/nested mapping paths. +Resolved for the known sentinel path. Ignored/nested mappings now use an explicit internal `IMemberMap` marker. ### Probabilidade -Baixa. Current tests exercise ignored behavior, and the sentinel is internal. The risk is mostly future compatibility and maintainability. +Baixa for future Dapper fallback behavior, but the specific `NotImplementedException` sentinel risk is resolved. ### Workaround atual -None for consumers except staying on tested Dapper versions and using documented APIs. +None needed for this issue. ### Recomendacao -Replace the sentinel with an explicit `IMemberMap` or strategy result that never exposes a throwing `PropertyInfo`, if this can be done without breaking Dapper behavior. +Keep `DapperCompatibilityAdapterTests` in the Dapper upgrade checklist to verify ignored mappings still block fallback. ### Relacoes -Related to FM-RISK-007 and future Dapper compatibility work. +Related to FM-RISK-007 and future Dapper compatibility work, but no longer tracked as active technical debt. ## FM-RISK-013 - Dommel behavior for profiles/nested materialization is intentionally unreviewed @@ -935,7 +940,6 @@ Related to FM-RISK-016. | FM-RISK-018 | README maintenance-status inconsistency | README/SDD | Consumer confusion | P2 | | FM-RISK-017 | Remote CI evidence missing | .NET 10 migration | Release confidence | P2 | | FM-RISK-010 | Legacy assembly scanning API behavior | Etapa 3 | Maintenance/diagnostic debt | P3 | -| FM-RISK-012 | Throwing sentinel `IgnoredPropertyInfo` | Etapa 2 | Future compatibility debt | P3 | | FM-RISK-014 | Partial analyzer/generator coverage | Etapa 4 | Compile-time feedback gaps | P3 | | FM-RISK-011 | Dapper-delegated constructor edge cases | Etapa 3 | Edge-case diagnostics | P3 | | FM-RISK-013 | Dommel profile/nested review missing | Etapa 5 | Extension clarity | P3 | @@ -985,7 +989,7 @@ Related to FM-RISK-016. 7. Run a dedicated Dommel profile/nested review and document whether integration is intentionally unsupported. 8. Modernize NuGet metadata and README status in a documentation/packaging-only delivery. 9. Record remote CI outcomes after the next push. -10. Revisit lower-level compatibility debt: `IgnoredPropertyInfo`, reflective TypeHandler adapter and legacy scanning API. +10. Revisit lower-level compatibility debt: reflective TypeHandler adapter and legacy scanning API. ## 13. Architectural Health Assessment diff --git a/src/Dapper.FluentMap/Compatibility/DapperFluentPropertyTypeMap.cs b/src/Dapper.FluentMap/Compatibility/DapperFluentPropertyTypeMap.cs new file mode 100644 index 0000000..7b68f15 --- /dev/null +++ b/src/Dapper.FluentMap/Compatibility/DapperFluentPropertyTypeMap.cs @@ -0,0 +1,50 @@ +using System; +using System.Reflection; +using Dapper.FluentMap.Mapping; + +namespace Dapper.FluentMap.Compatibility +{ + internal sealed class DapperFluentPropertyTypeMap : SqlMapper.ITypeMap + { + private readonly Type _type; + private readonly Func _propertyMapResolver; + + internal DapperFluentPropertyTypeMap(Type type, Func propertyMapResolver) + { + _type = type ?? throw new ArgumentNullException(nameof(type)); + _propertyMapResolver = propertyMapResolver ?? throw new ArgumentNullException(nameof(propertyMapResolver)); + } + + public ConstructorInfo FindConstructor(string[] names, Type[] types) + { + return null; + } + + public ConstructorInfo FindExplicitConstructor() + { + return null; + } + + public SqlMapper.IMemberMap GetConstructorParameter(ConstructorInfo constructor, string columnName) + { + return null; + } + + public SqlMapper.IMemberMap GetMember(string columnName) + { + var map = _propertyMapResolver(_type, columnName); + if (map == null) + { + return null; + } + + var memberPath = PropertyMapIdentity.GetMemberPath(map); + if (map.Ignored || memberPath.IsNested) + { + return new DapperIgnoredMemberMap(columnName); + } + + return new DapperPropertyMemberMap(columnName, map.PropertyInfo); + } + } +} diff --git a/src/Dapper.FluentMap/Compatibility/DapperIgnoredMemberMap.cs b/src/Dapper.FluentMap/Compatibility/DapperIgnoredMemberMap.cs new file mode 100644 index 0000000..946c204 --- /dev/null +++ b/src/Dapper.FluentMap/Compatibility/DapperIgnoredMemberMap.cs @@ -0,0 +1,28 @@ +using System; +using System.Reflection; + +namespace Dapper.FluentMap.Compatibility +{ + internal sealed class DapperIgnoredMemberMap : SqlMapper.IMemberMap + { + internal DapperIgnoredMemberMap(string columnName) + { + ColumnName = columnName ?? throw new ArgumentNullException(nameof(columnName)); + } + + public string ColumnName { get; } + + public Type MemberType => typeof(object); + + public PropertyInfo Property => null; + + public FieldInfo Field => null; + + public ParameterInfo Parameter => null; + + internal static bool IsIgnored(SqlMapper.IMemberMap memberMap) + { + return memberMap is DapperIgnoredMemberMap; + } + } +} diff --git a/src/Dapper.FluentMap/Compatibility/DapperPropertyMemberMap.cs b/src/Dapper.FluentMap/Compatibility/DapperPropertyMemberMap.cs new file mode 100644 index 0000000..bdc393e --- /dev/null +++ b/src/Dapper.FluentMap/Compatibility/DapperPropertyMemberMap.cs @@ -0,0 +1,24 @@ +using System; +using System.Reflection; + +namespace Dapper.FluentMap.Compatibility +{ + internal sealed class DapperPropertyMemberMap : SqlMapper.IMemberMap + { + internal DapperPropertyMemberMap(string columnName, PropertyInfo property) + { + ColumnName = columnName ?? throw new ArgumentNullException(nameof(columnName)); + Property = property ?? throw new ArgumentNullException(nameof(property)); + } + + public string ColumnName { get; } + + public Type MemberType => Property.PropertyType; + + public PropertyInfo Property { get; } + + public FieldInfo Field => null; + + public ParameterInfo Parameter => null; + } +} diff --git a/src/Dapper.FluentMap/Compatibility/DapperTypeHandlerAdapter.cs b/src/Dapper.FluentMap/Compatibility/DapperTypeHandlerAdapter.cs new file mode 100644 index 0000000..665406e --- /dev/null +++ b/src/Dapper.FluentMap/Compatibility/DapperTypeHandlerAdapter.cs @@ -0,0 +1,116 @@ +using System; +using System.Data; +using System.Linq.Expressions; +using System.Reflection; + +namespace Dapper.FluentMap.Compatibility +{ + internal static class DapperTypeHandlerAdapter + { + private const string TypeHandlerCacheName = "TypeHandlerCache`1"; + + internal static bool HasTypeHandler(Type targetType) + { + if (targetType == null) + { + throw new ArgumentNullException(nameof(targetType)); + } + + return SqlMapper.HasTypeHandler(GetHandlerType(targetType)); + } + + internal static Func CreateConverter(Type targetType) + { + return CreateConverter(targetType, ResolveTypeHandlerCacheDefinition); + } + + internal static Func CreateConverter(Type targetType, Func cacheDefinitionResolver) + { + if (targetType == null) + { + throw new ArgumentNullException(nameof(targetType)); + } + + if (cacheDefinitionResolver == null) + { + throw new ArgumentNullException(nameof(cacheDefinitionResolver)); + } + + var handlerType = GetHandlerType(targetType); + var cacheTypeDefinition = cacheDefinitionResolver(); + if (cacheTypeDefinition == null) + { + throw CreateCompatibilityException(targetType, "nested TypeHandlerCache type was not found"); + } + + MethodInfo parse; + try + { + var cacheType = cacheTypeDefinition.MakeGenericType(handlerType); + parse = cacheType.GetMethod( + "Parse", + BindingFlags.Public | BindingFlags.Static, + null, + new[] { typeof(object) }, + null); + } + catch (Exception exception) + { + throw CreateCompatibilityException(targetType, "TypeHandlerCache.Parse could not be resolved", exception); + } + + if (parse == null) + { + throw CreateCompatibilityException(targetType, "TypeHandlerCache.Parse(object) was not found"); + } + + return CreateParseDelegate(targetType, parse); + } + + private static Func CreateParseDelegate(Type targetType, MethodInfo parse) + { + var value = Expression.Parameter(typeof(object), "value"); + var nullValue = Expression.Constant(GetDefaultValue(targetType), typeof(object)); + var body = Expression.Condition( + Expression.OrElse( + Expression.Equal(value, Expression.Constant(null, typeof(object))), + Expression.Equal(value, Expression.Constant(DBNull.Value, typeof(object)))), + nullValue, + Expression.Convert(Expression.Call(parse, value), typeof(object))); + + return Expression.Lambda>(body, value).Compile(); + } + + private static Type ResolveTypeHandlerCacheDefinition() + { + return typeof(SqlMapper).GetNestedType(TypeHandlerCacheName, BindingFlags.Public | BindingFlags.NonPublic); + } + + private static Type GetHandlerType(Type targetType) + { + return Nullable.GetUnderlyingType(targetType) ?? targetType; + } + + private static object GetDefaultValue(Type type) + { + if (!type.GetTypeInfo().IsValueType || Nullable.GetUnderlyingType(type) != null) + { + return null; + } + + return Activator.CreateInstance(type); + } + + private static FluentMapConfigurationException CreateCompatibilityException(Type targetType, string reason, Exception innerException = null) + { + var message = + $"Dapper TypeHandler compatibility failed for target type '{targetType.FullName}': {reason}. " + + "This FluentMap version expects Dapper to expose SqlMapper.TypeHandlerCache.Parse(object). " + + "Review the Dapper compatibility boundary before upgrading Dapper."; + + return innerException == null + ? new FluentMapConfigurationException(message) + : new FluentMapConfigurationException(message, innerException); + } + } +} diff --git a/src/Dapper.FluentMap/MappingRegistry.cs b/src/Dapper.FluentMap/MappingRegistry.cs index e12205c..54e1570 100644 --- a/src/Dapper.FluentMap/MappingRegistry.cs +++ b/src/Dapper.FluentMap/MappingRegistry.cs @@ -776,19 +776,9 @@ internal MappingCacheEntry(IPropertyMap propertyMap) if (!propertyMap.Ignored) { var memberPath = PropertyMapIdentity.GetMemberPath(propertyMap); - PropertyInfo = memberPath.IsNested -#if !NETSTANDARD1_3 - ? new IgnoredPropertyInfo() -#else - ? null -#endif - : propertyMap.PropertyInfo; + PropertyInfo = memberPath.IsNested ? null : propertyMap.PropertyInfo; return; } - -#if !NETSTANDARD1_3 - PropertyInfo = new IgnoredPropertyInfo(); -#endif } internal IPropertyMap PropertyMap { get; } diff --git a/src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs b/src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs index 6e549c4..0365fcb 100644 --- a/src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs +++ b/src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Linq.Expressions; using System.Reflection; +using Dapper.FluentMap.Compatibility; using Dapper.FluentMap.Mapping; namespace Dapper.FluentMap.Materialization @@ -148,42 +149,14 @@ private static Action CreateFieldSetter(FieldInfo field) private static Func CreateConverter(Type targetType) { var conversionType = Nullable.GetUnderlyingType(targetType) ?? targetType; - if (SqlMapper.HasTypeHandler(conversionType)) + if (DapperTypeHandlerAdapter.HasTypeHandler(conversionType)) { - return CreateTypeHandlerConverter(conversionType); + return DapperTypeHandlerAdapter.CreateConverter(targetType); } return value => ConvertValue(value, targetType); } - private static Func CreateTypeHandlerConverter(Type targetType) - { - var value = Expression.Parameter(typeof(object), "value"); - var cacheTypeDefinition = typeof(SqlMapper).GetNestedType("TypeHandlerCache`1", BindingFlags.Public | BindingFlags.NonPublic); - if (cacheTypeDefinition == null) - { - return raw => ConvertValue(raw, targetType); - } - - var cacheType = cacheTypeDefinition.MakeGenericType(targetType); - var parse = cacheType.GetMethod("Parse", BindingFlags.Public | BindingFlags.Static, null, new[] { typeof(object) }, null); - - if (parse == null) - { - return raw => ConvertValue(raw, targetType); - } - - var nullValue = Expression.Constant(GetDefaultValue(targetType), typeof(object)); - var body = Expression.Condition( - Expression.OrElse( - Expression.Equal(value, Expression.Constant(null, typeof(object))), - Expression.Equal(value, Expression.Constant(DBNull.Value, typeof(object)))), - nullValue, - Expression.Convert(Expression.Call(parse, value), typeof(object))); - - return Expression.Lambda>(body, value).Compile(); - } - private static object ConvertValue(object value, Type targetType) { if (value == null || value == DBNull.Value) diff --git a/src/Dapper.FluentMap/TypeMaps/FluentConventionTypeMap.cs b/src/Dapper.FluentMap/TypeMaps/FluentConventionTypeMap.cs index b126c14..cc46bfe 100644 --- a/src/Dapper.FluentMap/TypeMaps/FluentConventionTypeMap.cs +++ b/src/Dapper.FluentMap/TypeMaps/FluentConventionTypeMap.cs @@ -1,26 +1,24 @@ using System; -using System.Reflection; +using Dapper.FluentMap.Compatibility; using Dapper.FluentMap.Mapping; namespace Dapper.FluentMap.TypeMaps { /// - /// Represents a Dapper type mapping strategy which first tries to map the type using a - /// - /// with the configured conventions. is used as fallback mapping strategy. + /// Represents a Dapper type mapping strategy which first tries configured conventions. + /// is used as fallback mapping strategy. /// /// The type of the entity. public class FluentConventionTypeMap : MultiTypeMap { /// /// Initializes a new instance of the class - /// which uses the and - /// as mapping strategies. + /// which uses FluentMap conventions and as mapping strategies. /// public FluentConventionTypeMap() : base( new FluentConstructorTypeMap(typeof(TEntity), GetPropertyMap), - new CustomPropertyTypeMap(typeof(TEntity), GetPropertyInfo), + new DapperFluentPropertyTypeMap(typeof(TEntity), GetPropertyMap), new DefaultTypeMap(typeof(TEntity))) { } @@ -30,9 +28,5 @@ private static IPropertyMap GetPropertyMap(Type type, string columnName) return FluentMapper.Registry.GetConventionPropertyMap(type, columnName); } - private static PropertyInfo GetPropertyInfo(Type type, string columnName) - { - return FluentMapper.Registry.GetConventionPropertyInfo(type, columnName); - } } } diff --git a/src/Dapper.FluentMap/TypeMaps/FluentMapTypeMap.cs b/src/Dapper.FluentMap/TypeMaps/FluentMapTypeMap.cs index 649d34f..49d303d 100644 --- a/src/Dapper.FluentMap/TypeMaps/FluentMapTypeMap.cs +++ b/src/Dapper.FluentMap/TypeMaps/FluentMapTypeMap.cs @@ -1,5 +1,5 @@ using System; -using System.Reflection; +using Dapper.FluentMap.Compatibility; using Dapper.FluentMap.Mapping; namespace Dapper.FluentMap.TypeMaps @@ -9,7 +9,7 @@ internal sealed class FluentMapTypeMap : MultiTypeMap internal FluentMapTypeMap(Type entityType) : base( new FluentConstructorTypeMap(entityType, GetPropertyMap), - new CustomPropertyTypeMap(entityType, GetPropertyInfo), + new DapperFluentPropertyTypeMap(entityType, GetPropertyMap), new DefaultTypeMap(entityType)) { } @@ -19,9 +19,5 @@ private static IPropertyMap GetPropertyMap(Type type, string columnName) return FluentMapper.Registry.GetFluentPropertyMap(type, columnName); } - private static PropertyInfo GetPropertyInfo(Type type, string columnName) - { - return FluentMapper.Registry.GetFluentPropertyInfo(type, columnName); - } } } diff --git a/src/Dapper.FluentMap/TypeMaps/FluentTypeMap.cs b/src/Dapper.FluentMap/TypeMaps/FluentTypeMap.cs index 0c7014c..2474fed 100644 --- a/src/Dapper.FluentMap/TypeMaps/FluentTypeMap.cs +++ b/src/Dapper.FluentMap/TypeMaps/FluentTypeMap.cs @@ -1,5 +1,5 @@ using System; -using System.Reflection; +using Dapper.FluentMap.Compatibility; using Dapper.FluentMap.Mapping; namespace Dapper.FluentMap.TypeMaps @@ -19,7 +19,7 @@ public class FluentMapTypeMap : MultiTypeMap public FluentMapTypeMap() : base( new FluentConstructorTypeMap(typeof(TEntity), GetPropertyMap), - new CustomPropertyTypeMap(typeof(TEntity), GetPropertyInfo), + new DapperFluentPropertyTypeMap(typeof(TEntity), GetPropertyMap), new DefaultTypeMap(typeof(TEntity))) { } @@ -29,9 +29,5 @@ private static IPropertyMap GetPropertyMap(Type type, string columnName) return FluentMapper.Registry.GetFluentPropertyMap(type, columnName); } - private static PropertyInfo GetPropertyInfo(Type type, string columnName) - { - return FluentMapper.Registry.GetFluentPropertyInfo(type, columnName); - } } } diff --git a/src/Dapper.FluentMap/TypeMaps/IgnoredPropertyInfo.cs b/src/Dapper.FluentMap/TypeMaps/IgnoredPropertyInfo.cs deleted file mode 100644 index b396930..0000000 --- a/src/Dapper.FluentMap/TypeMaps/IgnoredPropertyInfo.cs +++ /dev/null @@ -1,28 +0,0 @@ -#if !NETSTANDARD1_3 -using System; -using System.Globalization; -using System.Reflection; - -namespace Dapper.FluentMap.TypeMaps -{ - internal class IgnoredPropertyInfo : PropertyInfo - { - public override Type PropertyType => throw new NotImplementedException(); - public override PropertyAttributes Attributes => throw new NotImplementedException(); - public override bool CanRead => throw new NotImplementedException(); - public override bool CanWrite => throw new NotImplementedException(); - public override string Name => throw new NotImplementedException(); - public override Type DeclaringType => throw new NotImplementedException(); - public override ParameterInfo[] GetIndexParameters() => throw new NotImplementedException(); - public override Type ReflectedType => throw new NotImplementedException(); - public override MethodInfo[] GetAccessors(bool nonPublic) => throw new NotImplementedException(); - public override object[] GetCustomAttributes(bool inherit) => throw new NotImplementedException(); - public override object[] GetCustomAttributes(Type attributeType, bool inherit) => throw new NotImplementedException(); - public override MethodInfo GetGetMethod(bool nonPublic) => throw new NotImplementedException(); - public override MethodInfo GetSetMethod(bool nonPublic) => throw new NotImplementedException(); - public override bool IsDefined(Type attributeType, bool inherit) => throw new NotImplementedException(); - public override object GetValue(object obj, BindingFlags invokeAttr, Binder binder, object[] index, CultureInfo culture) => throw new NotImplementedException(); - public override void SetValue(object obj, object value, BindingFlags invokeAttr, Binder binder, object[] index, CultureInfo culture) => throw new NotImplementedException(); - } -} -#endif \ No newline at end of file diff --git a/src/Dapper.FluentMap/TypeMaps/MultiTypeMap.cs b/src/Dapper.FluentMap/TypeMaps/MultiTypeMap.cs index 7dd3c22..292bfe9 100644 --- a/src/Dapper.FluentMap/TypeMaps/MultiTypeMap.cs +++ b/src/Dapper.FluentMap/TypeMaps/MultiTypeMap.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Reflection; +using Dapper.FluentMap.Compatibility; using Dapper.FluentMap.Mapping; namespace Dapper.FluentMap.TypeMaps @@ -39,12 +40,12 @@ public ConstructorInfo FindConstructor(string[] names, Type[] types) } catch (NotImplementedException) { - // Ignore NotImplementedException's thrown by the CustomPropertyTypeMap + // Ignore unsupported operations from mapper strategies // and continue to the next mapping strategy. } catch (NotSupportedException) { - // Ignore NotSupportedException's thrown by the CustomPropertyTypeMap + // Ignore unsupported operations from mapper strategies // and continue to the next mapping strategy. } } @@ -67,12 +68,12 @@ public ConstructorInfo FindExplicitConstructor() } catch (NotImplementedException) { - // Ignore NotImplementedException's thrown by the CustomPropertyTypeMap + // Ignore unsupported operations from mapper strategies // and continue to the next mapping strategy. } catch (NotSupportedException) { - // Ignore NotSupportedException's thrown by the CustomPropertyTypeMap + // Ignore unsupported operations from mapper strategies // and continue to the next mapping strategy. } } @@ -96,12 +97,12 @@ public SqlMapper.IMemberMap GetConstructorParameter(ConstructorInfo constructor, } catch (NotImplementedException) { - // Ignore NotImplementedException's thrown by the CustomPropertyTypeMap + // Ignore unsupported operations from mapper strategies // and continue to the next mapping strategy. } catch (NotSupportedException) { - // Ignore NotSupportedException's thrown by the CustomPropertyTypeMap + // Ignore unsupported operations from mapper strategies // and continue to the next mapping strategy. } } @@ -119,20 +120,18 @@ public SqlMapper.IMemberMap GetMember(string columnName) var result = mapper.GetMember(columnName); if (result != null) { -#if !NETSTANDARD1_3 - if (result is IgnoredPropertyInfo || result.Property is IgnoredPropertyInfo) + if (DapperIgnoredMemberMap.IsIgnored(result)) { - // The property is explicitly ignored, + // The property is explicitly ignored or FluentMap-controlled nested materialization. // return null to prevent falling back to default type map of Dapper. return null; } -#endif return result; } } catch (NotImplementedException) { - // Ignore NotImplementedException's thrown by the CustomPropertyTypeMap + // Ignore unsupported operations from mapper strategies // and continue to the next mapping strategy. } } diff --git a/test/Dapper.FluentMap.Tests/DapperCompatibilityAdapterTests.cs b/test/Dapper.FluentMap.Tests/DapperCompatibilityAdapterTests.cs new file mode 100644 index 0000000..8978a2b --- /dev/null +++ b/test/Dapper.FluentMap.Tests/DapperCompatibilityAdapterTests.cs @@ -0,0 +1,329 @@ +using System; +using Dapper; +using Dapper.FluentMap.Compatibility; +using Dapper.FluentMap.Mapping; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class DapperCompatibilityAdapterTests + { + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldUseRegisteredDapperTypeHandler() + { + PreTest(typeof(TypeHandlerCustomer)); + + try + { + SqlMapper.AddTypeHandler(new CpfTypeHandler()); + FluentMapper.Initialize(c => c.AddMap(new TypeHandlerCustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT '12345678909' AS cpf;"); + + Assert.NotNull(customer.Cpf); + Assert.Equal("12345678909", customer.Cpf.Number); + } + } + finally + { + SqlMapper.ResetTypeHandlers(); + PreTest(typeof(TypeHandlerCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldUseRegisteredDapperTypeHandlerForNullableValue() + { + PreTest(typeof(NullableHandlerCustomer)); + + try + { + SqlMapper.AddTypeHandler(new SmallCodeTypeHandler()); + FluentMapper.Initialize(c => c.AddMap(new NullableHandlerCustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 7 AS code;"); + + Assert.True(customer.Code.HasValue); + Assert.Equal(7, customer.Code.Value.Value); + } + } + finally + { + SqlMapper.ResetTypeHandlers(); + PreTest(typeof(NullableHandlerCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldKeepNullableTypeHandlerValueNullWhenColumnIsDbNull() + { + PreTest(typeof(NullableHandlerCustomer)); + + try + { + SqlMapper.AddTypeHandler(new SmallCodeTypeHandler()); + FluentMapper.Initialize(c => c.AddMap(new NullableHandlerCustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT NULL AS code;"); + + Assert.False(customer.Code.HasValue); + } + } + finally + { + SqlMapper.ResetTypeHandlers(); + PreTest(typeof(NullableHandlerCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldUseDefaultConversionWhenNoTypeHandlerIsRegistered() + { + PreTest(typeof(DefaultConversionCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new DefaultConversionCustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT '42' AS customer_id;"); + + Assert.Equal(42, customer.Id); + } + } + finally + { + PreTest(typeof(DefaultConversionCustomer)); + } + } + + [Fact] + public void TypeHandlerBoundaryShouldFailWithDiagnosticWhenDapperCacheShapeIsMissing() + { + var exception = Assert.Throws( + () => DapperTypeHandlerAdapter.CreateConverter(typeof(Cpf), () => null)); + + Assert.Contains("Dapper TypeHandler compatibility failed", exception.Message); + Assert.Contains("TypeHandlerCache", exception.Message); + Assert.Contains("upgrading Dapper", exception.Message); + } + + [Fact] + [Trait("Category", "Integration")] + public void DapperQueryShouldNotMapIgnoredRootPropertyOrFallbackToDefault() + { + PreTest(typeof(IgnoredRootCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new IgnoredRootCustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QuerySingle( + "SELECT 99 AS Id, 'Ada' AS Name;"); + + Assert.Equal(0, customer.Id); + Assert.Equal("Ada", customer.Name); + } + } + finally + { + PreTest(typeof(IgnoredRootCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void DapperQueryShouldNotMapIgnoredNestedPathOrFallbackToRootProperty() + { + PreTest(typeof(IgnoredNestedCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new IgnoredNestedCustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QuerySingle( + "SELECT 'leaked' AS City;"); + + Assert.Null(customer.City); + Assert.Null(customer.Address); + } + } + finally + { + PreTest(typeof(IgnoredNestedCustomer)); + } + } + + [Fact] + public void TypeMapShouldReturnNullForIgnoredMemberWithoutThrowing() + { + PreTest(typeof(IgnoredRootCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new IgnoredRootCustomerMap())); + + var typeMap = SqlMapper.GetTypeMap(typeof(IgnoredRootCustomer)); + var member = typeMap.GetMember("Id"); + + Assert.Null(member); + } + finally + { + PreTest(typeof(IgnoredRootCustomer)); + } + } + + private static SqliteConnection OpenConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + + private static void PreTest(params Type[] types) + { + FluentMapper.Reset(types); + } + + private sealed class TypeHandlerCustomer + { + public Cpf Cpf { get; set; } + } + + private sealed class TypeHandlerCustomerMap : EntityMap + { + public TypeHandlerCustomerMap() + { + Map(customer => customer.Cpf).ToColumn("cpf"); + } + } + + private sealed class Cpf + { + public Cpf(string number) + { + Number = number; + } + + public string Number { get; } + } + + private sealed class CpfTypeHandler : SqlMapper.TypeHandler + { + public override Cpf Parse(object value) + { + return new Cpf((string)value); + } + + public override void SetValue(System.Data.IDbDataParameter parameter, Cpf value) + { + parameter.Value = value == null ? DBNull.Value : value.Number; + } + } + + private sealed class NullableHandlerCustomer + { + public SmallCode? Code { get; set; } + } + + private sealed class NullableHandlerCustomerMap : EntityMap + { + public NullableHandlerCustomerMap() + { + Map(customer => customer.Code).ToColumn("code"); + } + } + + private readonly struct SmallCode + { + public SmallCode(int value) + { + Value = value; + } + + public int Value { get; } + } + + private sealed class SmallCodeTypeHandler : SqlMapper.TypeHandler + { + public override SmallCode Parse(object value) + { + return new SmallCode(Convert.ToInt32(value)); + } + + public override void SetValue(System.Data.IDbDataParameter parameter, SmallCode value) + { + parameter.Value = value.Value; + } + } + + private sealed class DefaultConversionCustomer + { + public int Id { get; set; } + } + + private sealed class DefaultConversionCustomerMap : EntityMap + { + public DefaultConversionCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + } + } + + private sealed class IgnoredRootCustomer + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private sealed class IgnoredRootCustomerMap : EntityMap + { + public IgnoredRootCustomerMap() + { + Map(customer => customer.Id).Ignore(); + } + } + + private sealed class IgnoredNestedCustomer + { + public string City { get; set; } + + public IgnoredAddress Address { get; set; } + } + + private sealed class IgnoredAddress + { + public string City { get; set; } + } + + private sealed class IgnoredNestedCustomerMap : EntityMap + { + public IgnoredNestedCustomerMap() + { + Map(customer => customer.Address.City).Ignore(); + } + } + } +} From 63effef80f22b30a536ac2291b8140cd04019413 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sun, 26 Jul 2026 19:39:02 -0300 Subject: [PATCH 18/20] chore(fluentmap): evaluate generated materializer architecture --- README.md | 11 +- .../04-generated-materializer-spike.md | 570 ++++++++++++++++++ docs/sdd/etapa-6/README.md | 23 +- docs/sdd/etapa-6/decisions.md | 69 +++ docs/sdd/etapa-6/handoff.md | 346 ++++------- docs/sdd/fluentmap-risk-assessment.md | 4 +- .../GeneratedMaterializerSpikeTests.cs | 162 +++++ 7 files changed, 945 insertions(+), 240 deletions(-) create mode 100644 docs/sdd/etapa-6/04-generated-materializer-spike.md create mode 100644 test/Dapper.FluentMap.Tests/GeneratedMaterializerSpikeTests.cs diff --git a/README.md b/README.md index 6d9f95b..2f32d7f 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ var customer = connection.QueryMappedSingle( "SELECT 1 AS id, '12345678909' AS cpf"); ``` -The regular `Dapper.Query()` path continues to handle root properties, conventions, constructor mapping, TypeHandlers and Dapper fallback as before. For scalar Value Objects mapped as a whole, such as `Map(c => c.Cpf).ToColumn("cpf")`, prefer a Dapper `TypeHandler`. For nested paths such as `Map(c => c.Cpf.Number)`, `QueryMapped*` constructs the Value Object through public constructors and preserves domain invariants. Factory methods and generated materializers are not part of this runtime path. +The regular `Dapper.Query()` path continues to handle root properties, conventions, constructor mapping, TypeHandlers and Dapper fallback as before. For scalar Value Objects mapped as a whole, such as `Map(c => c.Cpf).ToColumn("cpf")`, prefer a Dapper `TypeHandler`. For nested paths such as `Map(c => c.Cpf.Number)`, `QueryMapped*` constructs the Value Object through public constructors and preserves domain invariants. Factory methods and generated materializers are not part of this runtime path; the generated materializer direction is documented as a future architecture spike, not a production feature. #### Mapping profiles When the same entity needs different SQL shapes, register an opt-in mapping profile and select it explicitly per query. Profiles do not replace the Dapper type map global for the entity. @@ -322,3 +322,12 @@ FluentMapper.Initialize(config => - `QueryMapped*` permanece reflection-based e anotado para trimming/AOT; o generator atual gera registro, nao materializer de `DbDataReader`. - Limitacoes principais: sem per-profile conventions, sem multi-mapping com profile, sem streaming unbuffered e sem factory methods para Value Objects. - Relatorios: `docs/sdd/etapa-5/01-nested-materialization-spike.md`, `docs/sdd/etapa-5/02-nested-object-materialization.md`, `docs/sdd/etapa-5/03-value-objects.md`, `docs/sdd/etapa-5/04-mapping-profiles.md`. + +## Resultado da Etapa 6 + +- Configuration lifecycle formalizado como startup/configuration seguido de operational phase read-only. +- Mapping state ganhou snapshots read-only, preservando campos publicos mutaveis por compatibilidade. +- Compatibilidade Dapper-specific foi isolada em adapters internos; `IgnoredPropertyInfo` foi removido. +- Spike de generated `DbDataReader` materializer concluiu `GO WITH CONSTRAINTS`: geracao e tecnicamente viavel para mappings estaticos, mas deve coexistir com runtime fallback. +- `FM-RISK-004` nao foi resolvido pelo spike; ele recebeu evidencia e arquitetura recomendada para uma etapa futura. +- Relatorios: `docs/sdd/etapa-6/01-configuration-lifecycle.md`, `docs/sdd/etapa-6/02-mapping-state-encapsulation.md`, `docs/sdd/etapa-6/03-dapper-compatibility-adapters.md`, `docs/sdd/etapa-6/04-generated-materializer-spike.md`. diff --git a/docs/sdd/etapa-6/04-generated-materializer-spike.md b/docs/sdd/etapa-6/04-generated-materializer-spike.md new file mode 100644 index 0000000..aa34ac4 --- /dev/null +++ b/docs/sdd/etapa-6/04-generated-materializer-spike.md @@ -0,0 +1,570 @@ +# 04 - Generated Materializer Spike + +Status: COMPLETED + +## Current Architecture + +`QueryMapped*` e o caminho opt-in atual para materializacao controlada pelo FluentMap. Ele executa o comando pelo Dapper, abre um `IDataReader`, coleta os nomes das colunas e pede ao `MappingRegistry` um `NestedMaterializationPlan` cacheado por: + +```text +EntityType + ProfileType + ordered column names +``` + +O plano runtime: + +- resolve mappings efetivos por coluna, incluindo profile opcional; +- preserva `MemberPath` completo para paths como `Rank.Level` e `Seniority.Level`; +- aplica precedencia de mapping explicito, convention/naming policy e fallback default do Dapper; +- constroi objetos aninhados mutaveis por construtor publico sem parametros e setters publicos; +- constroi Value Objects e objetos imutaveis por construtores publicos compativeis; +- decide `DBNull`/null por subarvore; +- usa `DapperTypeHandlerAdapter` para TypeHandlers escalares; +- compila delegates com `Expression.Compile`. + +As APIs publicas `QueryMapped*` permanecem anotadas com: + +```text +RequiresUnreferencedCode +RequiresDynamicCode +``` + +O source generator atual (`Dapper.FluentMap.Generators`) gera apenas registro: + +```csharp +configuration.AddGeneratedMappings(); +``` + +Ele descobre maps na compilacao atual e emite `AddMap()` ou `AddProfile()`. Ele nao le `DbDataReader`, nao interpreta todo o corpo do map e nao gera materializers. + +## Problem + +`FM-RISK-004` permanece: `QueryMapped*` depende de reflection runtime e dynamic code para gerar accessors, factories, conversores e chamadas de construtor. Isso limita uso em trimming/Native AOT e cria custo de primeira query por plano. + +O spike investiga se e tecnicamente viavel gerar materializers de `DbDataReader` em compile-time para o subconjunto de mappings do FluentMap que pode ser conhecido estaticamente, preservando fallback runtime para configuracao dinamica. + +## Research Questions + +1. Qual metadata o source generator consegue obter em compile-time? +2. Quais mappings sao estaticos e detectaveis? +3. Quais mappings podem ser construidos dinamicamente e portanto nao sao geraveis? +4. Como profiles poderiam ser representados? +5. Como `MemberPath` poderia virar codigo gerado? +6. Como nested mutable objects seriam materializados? +7. Como immutable Value Objects seriam materializados? +8. Como constructors seriam selecionados? +9. Como TypeHandlers seriam integrados? +10. Como `DBNull`/null seriam tratados? +11. Como conversoes seriam feitas? +12. Como naming policies/conventions afetariam geracao? +13. Como mappings registrados em assemblies externos seriam tratados? +14. Como caching mudaria? +15. Como generated e runtime materializer coexistiriam? + +## Experiments Performed + +Arquivos analisados: + +- `docs/sdd/etapa-4/02-trimming-aot.md`; +- `docs/sdd/etapa-4/03-source-generator.md`; +- `docs/sdd/etapa-5/01-nested-materialization-spike.md`; +- `docs/sdd/etapa-5/02-nested-object-materialization.md`; +- `docs/sdd/etapa-5/03-value-objects.md`; +- `docs/sdd/etapa-5/04-mapping-profiles.md`; +- `docs/sdd/etapa-6/01-configuration-lifecycle.md`; +- `docs/sdd/etapa-6/02-mapping-state-encapsulation.md`; +- `docs/sdd/etapa-6/03-dapper-compatibility-adapters.md`; +- `docs/sdd/fluentmap-risk-assessment.md`; +- `src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs`; +- `src/Dapper.FluentMap/QueryMappedExtensions.cs`; +- `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs`; +- `src/Dapper.FluentMap/MappingRegistry.cs`; +- `src/Dapper.FluentMap/Mapping/MemberPath.cs`; +- tests de generator, profiles, nested materialization, Value Objects e TypeHandler compatibility. + +Prototipo adicionado: + +- `test/Dapper.FluentMap.Tests/GeneratedMaterializerSpikeTests.cs`. + +O prototipo e intencionalmente test-only. Ele simula codigo que um generator poderia emitir: + +- usa ordinais fixos de `IDataRecord`; +- nao usa `Expression.Compile`; +- nao usa reflection para getter, setter ou construtor; +- materializa uma entidade simples com mapping explicito; +- materializa nested mutable object por subarvore; +- materializa Value Object imutavel por construtor; +- representa profile por metodo gerado separado; +- preserva `DBNull` como `null` em reference/Value Object nullable. + +## Prototype + +Forma conceitual validada pelo teste: + +```csharp +internal static GeneratedCustomer ReadLegacyProfile(IDataRecord record) +{ + return new GeneratedCustomer( + ReadInt32(record, 0), + record.IsDBNull(1) ? null : new GeneratedCpf(ReadString(record, 1)), + ReadString(record, 2)); +} +``` + +Esse codigo prova que, quando coluna, path, construtor e profile sao conhecidos, o materializer pode ser codigo direto contra `IDataRecord`/`DbDataReader`, sem a combinacao atual de reflection + expression compilation no runtime. + +O prototipo nao prova: + +- discovery automatica de todas as chamadas fluent no corpo de mapas; +- TypeHandler gerado; +- Native AOT runtime real; +- convencoes complexas; +- performance. + +## Findings + +### 1. Metadata disponivel em compile-time + +O generator atual ja consegue obter por Roslyn: + +- classes de map na compilacao atual; +- `IEntityMap`; +- `IProfileMap`; +- abstracao, genericidade, visibilidade e construtor publico sem parametros do map; +- hierarquia de tipos e symbols de entidades/propriedades/construtores quando referenciados no codigo fonte. + +Para materializer, o generator poderia obter mais metadata apenas se interpretar um subconjunto estatico da DSL: + +- chamadas `Map(x => x.Property)` e `Map(x => x.Nested.Property)`; +- chamadas `ToColumn("literal")`; +- chamadas `Ignore()`; +- `IncludeBase()`; +- `IProfileMap`. + +Ele nao deve executar o construtor do map. Construtores de maps sao codigo arbitrario. + +### 2. Mappings estaticos e detectaveis + +Geraveis com boa confianca: + +- maps declarados na compilacao atual; +- lambdas simples de member access; +- coluna literal em `ToColumn`; +- `Ignore`; +- profile por `IProfileMap`; +- `IncludeBase` quando base map geravel no mesmo contexto; +- constructor binding por nomes de propriedades/parametros visiveis no symbol model. + +### 3. Mappings dinamicos nao geraveis + +Nao geraveis sem fallback: + +- column names calculados por variavel, helper, config externa ou interpolacao nao constante; +- chamadas fluent escondidas em metodos arbitrarios; +- maps adicionados por `AddMap(new SomeMap(runtimeValue))`; +- assembly scanning; +- mutacao direta de `FluentMapper.EntityMaps` e `TypeConventions`; +- conventions customizadas que executam codigo no construtor; +- naming policies aplicadas dinamicamente; +- maps em assemblies referenciados sem um contrato de manifesto gerado; +- qualquer path que dependa de reflection runtime nao representada na compilacao atual. + +### 4. Profiles + +Profiles devem virar chaves geradas fortemente tipadas: + +```text +EntityType + ProfileType + ColumnShape +``` + +Cada profile geravel pode produzir um materializer separado ou um descriptor gerado separado. Isso preserva a decisao E6-D003: profile e query-scoped e nao troca `SqlMapper.SetTypeMap`. + +### 5. MemberPath como codigo gerado + +`MemberPath` pode virar uma cadeia de symbols no codigo gerado: + +```text +Customer.Cpf.Number -> constructor arg Cpf(number) +Customer.Address.City -> ensure Address then set City +``` + +Para o runtime gerado, a identidade precisa continuar sendo o path completo, nao apenas o terminal. Isso evita colisao entre `Rank.Level` e `Seniority.Level`. + +### 6. Nested mutable objects + +Codigo gerado pode emitir: + +```text +if any subtree column is non-null: + if parent.Address == null: + parent.Address = new Address() + parent.Address.City = value +else: + parent.Address = null when assignable +``` + +Isso e equivalente a semantica atual de subarvore e nao exige reflection se os setters/construtores forem publicos e conhecidos. + +### 7. Immutable Value Objects + +Codigo gerado pode emitir construcao bottom-up: + +```text +Cpf cpf = all cpf subtree columns are null ? null : new Cpf(number) +Customer customer = new Customer(id, cpf) +``` + +Construtores privados, setters privados, fields e `FormatterServices` continuam fora do contrato. Factory methods poderiam ser geradas futuramente apenas com API explicita. + +### 8. Constructor selection + +O generator pode usar symbols para selecionar construtores publicos por nome de parametro e tipo compativel, espelhando a regra atual. A parte sensivel e que o shape real de colunas vem do reader em runtime. Portanto a selecao gerada deve ser condicionada ao materializer gerado para aquele profile/map e ao conjunto de colunas suportado, com fallback se a query nao trouxer colunas esperadas. + +### 9. TypeHandlers + +Ha duas opcoes: + +- chamar diretamente um caminho Dapper generico conhecido para `T`, quando possivel; +- criar uma pequena API publica ou boundary geravel no core para converter via TypeHandler sem reflection por tipo arbitrario. + +O primeiro caminho reduz reflection, mas acopla codigo gerado a uma shape version-sensitive do Dapper. O segundo exige API publica nova e deve ser especificado antes de implementacao. A decisao da Entrega 03 permanece: nao espalhar reflection Dapper-specific. + +### 10. DBNull/null + +Codigo gerado deve preservar a regra atual: + +- `DBNull` em reference/nullable vira `null`; +- `DBNull` em value type nao anulavel vira default quando esse for o contrato atual; +- subarvore toda `NULL` vira objeto nested/value object `null`; +- subarvore parcialmente preenchida cria o objeto e passa `null`/default para folhas correspondentes. + +### 11. Conversoes + +Geravel: + +- typed getters quando o tipo da coluna for previsivel; +- `Convert.ToXxx`/`Convert.ChangeType` para fallback local; +- enum por string ou valor numerico; +- `Guid` por string; +- nullable wrappers. + +Ainda precisa de decisao: + +- cultura e provider; +- overflow/invalid cast diagnostics; +- TypeHandler sem reflection; +- conversoes customizadas publicas. + +### 12. Naming policies/conventions + +Mappings explicitos com colunas literais sao bons candidatos. + +Naming policies e conventions sao mais dificeis: + +- `NamingPolicy.SnakeCase` built-in pode ser geravel se registrado estaticamente; +- conventions customizadas sao objetos com codigo arbitrario e hoje populam `PropertyMaps` em runtime; +- per-profile conventions ainda nao existem. + +Recomendacao: primeira etapa gerada deve cobrir explicit maps e talvez naming policies built-in comprovaveis; conventions dinamicas devem usar fallback. + +### 13. Assemblies externos + +O generator atual descobre apenas maps da compilacao atual. Para assemblies externos ha opcoes: + +- cada assembly gera seu proprio manifesto/materializers e expõe um registro gerado local; +- o assembly consumidor referencia manifests de dependencies; +- fallback runtime para maps externos. + +O caminho mais compativel e permitir coexistencia: materializers gerados por assembly quando disponiveis, fallback runtime quando nao. + +### 14. Caching + +O cache runtime mudaria de plano unico para duas camadas: + +```text +GeneratedMaterializerRegistry + key: EntityType + ProfileType + ColumnShape + value: delegate/static descriptor gerado + +Runtime MaterializationPlanCache + key: EntityType + ProfileType + ColumnShape + value: NestedMaterializationPlan +``` + +O fallback runtime permanece essencial para dynamic maps. A invalidacao do registry deve remover qualquer cache runtime afetado, mas materializers gerados sao estaticos e so devem ser usados se a configuracao efetiva ainda corresponder ao descriptor gerado. + +### 15. Coexistencia generated/runtime + +Arquitetura recomendada: + +```text +QueryMapped + | + v +Resolve EntityType + ProfileType + ColumnShape + | + v +Generated materializer matches effective mapping? + | yes + v +Generated path + | + no + v +Runtime NestedMaterializationPlan fallback +``` + +O fallback deve ser transparente e diagnosticavel. Ele preserva compatibilidade com maps dinamicos e evita transformar o generator em requisito de runtime. + +## Architecture Comparison + +| Dimension | A. Runtime-only | B. Generated registration + runtime materializer | C. Generated materializer with runtime fallback | D. Fully generated-only path | +| --- | --- | --- | --- | --- | +| Compatibility | Alta; e a arquitetura atual | Alta; ja existe | Alta se fallback for padrao | Baixa; quebra maps dinamicos/scanning | +| AOT | Limitada por `QueryMapped*` anotado | Registro melhora, materializer nao | Melhor para casos gerados; fallback segue anotado | Melhor potencial, mas perde cobertura | +| Performance | Custo de plano/delegates na primeira query | Igual A para materializacao | Hipotese de menor first-query/hot path nos casos gerados | Melhor potencial, sem fallback | +| Dynamic maps | Suportados | Suportados | Suportados via fallback | Nao suportados | +| Profiles | Suportados runtime | Suportados runtime | Geraveis por `TProfile` + fallback | Apenas profiles gerados | +| Complexity | Media, ja paga | Media | Alta, mas incremental | Muito alta | +| Diagnostics | Runtime authoritative | Runtime authoritative | Precisa explicar generated vs fallback | Compile-time forte, runtime restrito | +| Maintenance | Concentrada no core | Core + generator registro | Core + generator + registry de materializers | Alto risco de dois mundos ou breaking changes | + +## AOT/Trimming Assessment + +### Proven + +- O runtime atual de `QueryMapped*` esta anotado com `RequiresUnreferencedCode` e `RequiresDynamicCode`. +- O generator atual nao gera materializers; ele gera registro e ja foi validado em smokes trimmed anteriores sem warnings FluentMap-owned no caminho gerado. +- A PoC test-only materializa simple/nested/value-object/profile/null sem `Expression.Compile`, sem reflection para members e sem `Activator` no hot path. +- Native AOT runtime completo nao foi validado neste ambiente nas etapas anteriores por ausencia do platform linker C++. + +### Likely + +- Um materializer gerado para mappings estaticos pode remover `Expression.Compile` do caminho gerado. +- Construtores publicos e setters publicos conhecidos podem ser chamados diretamente, reduzindo dependencia de reflection runtime. +- `MemberPath` gerado como cadeia de symbols reduz necessidade de preservar metadata de propriedades para o hot path. +- Fallback runtime ainda exigira manter as annotations atuais nas APIs que podem cair no caminho runtime. + +### Unknown + +- Se uma API publica AOT-safe para TypeHandlers arbitrarios pode ser oferecida sem depender de `SqlMapper.TypeHandlerCache.Parse`. +- Se todos os warnings dependency-owned do Dapper seriam removidos em um consumidor real. +- Runtime Native AOT real, porque nao ha validacao local com platform linker C++. +- Como representar conventions customizadas geradas sem executar codigo arbitrario. +- Como validar, no runtime, que a configuracao efetiva ainda corresponde ao materializer gerado quando public mutable dictionaries foram alterados diretamente. + +## Performance Hypotheses / Evidence + +Evidence: + +- A PoC elimina reflection/expression compilation no materializer test-only. +- O runtime atual cacheia planos, portanto o maior ganho esperado e em primeira query e hot path por row, nao em toda chamada igualmente. + +Hypothesis: + +- startup cost: pode aumentar ligeiramente por registrar manifests/materializers gerados; +- first query cost: deve cair para casos gerados porque nao ha criacao de `NestedMaterializationPlan` nem `Expression.Compile`; +- steady-state throughput: pode melhorar por chamadas diretas e menos indirection; +- allocation: pode reduzir objetos de plano/delegates e arrays de argumentos se construtores forem chamados diretamente; +- memory: pode trocar memoria runtime de cache por IL gerado no assembly consumidor. + +Nao ha benchmark formal nesta entrega. Nenhuma afirmacao de performance deve ser tratada como fato ate existir benchmark com Dapper default, `QueryMapped*` runtime e generated path. + +## Profile Implications + +Profiles combinam bem com geracao porque ja possuem identidade forte por `TProfile`. A geracao deve preservar: + +- `Dapper.Query()` usando somente default map; +- `QueryMapped()` selecionando profile por operacao; +- nenhum `SqlMapper.SetTypeMap` temporario; +- cache incluindo `ProfileType`; +- conventions/naming policies por entidade ate existir decisao de per-profile conventions. + +## Value Object Implications + +Geracao ajuda Value Objects por construtor porque o codigo pode ser bottom-up e direto: + +```text +leaf scalar values -> Value Object constructor -> root constructor/setter +``` + +Factory methods tambem poderiam ficar melhores em codigo gerado, mas somente se houver API publica explicita para selecionar a factory. A geracao e positiva para essa feature futura, desde que nao tente inferir factories por nome. + +## Streaming Implications + +Generated materializer facilita streaming porque separa: + +```text +reader lifecycle + from +row materialization delegate +``` + +Um futuro streaming/unbuffered path poderia iterar `DbDataReader.Read()` e chamar um materializer gerado por row sem armazenar tudo em `List`. + +Ainda assim, streaming exige uma entrega propria para: + +- ownership de connection/reader; +- enumeracao lazy sem reader ja disposto; +- async streaming; +- cancellation; +- disposal deterministico; +- comportamento em excecoes durante enumeracao. + +Este spike nao implementa streaming. + +## Runtime Fallback Strategy + +O fallback e obrigatorio. + +Regras recomendadas: + +- usar generated path apenas quando entity, profile e column shape forem reconhecidos; +- validar que a configuracao efetiva corresponde ao descriptor gerado; +- cair para runtime quando houver map dinamico, convention nao geravel, scanning, assembly externo sem manifest ou shape inesperado; +- expor diagnostico em `Explain` ou API futura para indicar se um shape usaria generated ou runtime; +- preservar annotations RUC/RDC nas APIs que ainda podem usar fallback runtime. + +## Compatibility Impact + +Uma futura implementacao pode ser minor-compatible se: + +- nao remover `QueryMapped*` runtime; +- nao exigir generator para consumidores atuais; +- nao alterar `Dapper.Query()`; +- nao remover public mutable dictionaries; +- nao tornar `Initialize(...)` one-shot; +- nao mudar TypeHandler semantics. + +Possivel impacto publico futuro: + +- pacote generator precisaria emitir materializer/manifest alem de registro; +- o core pode precisar de uma API publica pequena para registrar/descrever materializers gerados; +- diagnostics podem ganhar metadados de generated/fallback. + +## Risks + +- O generator pode aceitar apenas um subconjunto da DSL e surpreender consumidores se fallback nao for claro. +- Map constructors sao codigo arbitrario; tentar interpreta-los demais aumenta falso positivo/falso negativo. +- Public mutable dictionaries podem invalidar a correspondencia entre descriptor gerado e configuracao efetiva. +- TypeHandlers continuam sendo o ponto Dapper-specific mais delicado. +- Conventions customizadas e naming policies dinamicas podem limitar a cobertura gerada. +- Geracao por assembly exige desenho para dependencies e duplicidades. +- Sem benchmark, ganho de performance permanece hipotese. +- Sem Native AOT runtime, compatibilidade AOT completa permanece nao provada. + +## Recommendation + +`GO WITH CONSTRAINTS` + +E tecnicamente viavel gerar materializers de `DbDataReader` para um subconjunto estatico dos mappings do FluentMap: explicit maps com colunas literais, profiles tipados, paths por `MemberPath`, nested mutable objects e Value Objects por construtores publicos. A PoC test-only prova a forma essencial do codigo sem reflection/dynamic code no hot path. + +As restricoes sao obrigatorias: + +- generated materializer deve complementar, nao substituir, o runtime; +- fallback runtime deve permanecer a politica default para maps dinamicos; +- a primeira implementacao deve focar explicit/profile maps geraveis; +- TypeHandler gerado precisa de decisao propria antes de virar contrato; +- AOT deve ser validado por publish/run real antes de remover ou relaxar annotations publicas; +- performance precisa de benchmark antes de claims. + +## Proposed Next Stage + +Etapa 7 - Generated Materialization + +Sequencia sugerida derivada do spike: + +1. `Generated Materializer Contract` + - definir descriptor gerado, lookup por entity/profile/column shape e politica de fallback; + - decidir impacto publico minimo. +2. `Static Mapping DSL Discovery` + - estender generator para detectar somente `Map(...).ToColumn("literal")`, `Ignore`, `IncludeBase` e `IProfileMap`; + - emitir diagnostics informativos para maps nao geraveis. +3. `Generated Row Materializer Prototype` + - gerar materializer para simple root properties, nested mutable objects, immutable constructors e `DBNull` semantics; + - manter runtime fallback. +4. `Generated Profiles And Diagnostics` + - cobrir `TProfile`, inherited profile maps, `Explain`/diagnostic de generated vs fallback. +5. `TypeHandler And Conversion Strategy` + - escolher API/boundary para TypeHandlers sem espalhar reflection; + - validar nullable handlers. +6. `AOT/Trim And Performance Validation` + - publish trimmed; + - Native AOT em ambiente com linker C++; + - benchmark de startup, first query, throughput, allocation e memory. + +Dependencies: + +- manter Etapa 6 lifecycle; +- manter snapshots/read-only APIs; +- preservar compatibility boundary do Dapper; +- nao depender de Dommel; +- manter core `netstandard2.0`. + +Migration approach: + +- generator opt-in; +- runtime permanece autoritativo; +- fallback transparente; +- diagnostics para cobertura gerada. + +Testing strategy: + +- generator unit tests para discovery e codigo emitido; +- integration tests com SQLite para generated path; +- regression tests comparando runtime e generated para os mesmos SQL shapes; +- tests de fallback dinamico; +- trimmed smoke; +- Native AOT smoke quando ambiente permitir; +- benchmarks separados. + +## Validation Results + +Environment: + +- SDK: `10.0.302`; +- test runner detected: VSTest with xUnit v3; +- core target: `netstandard2.0`; +- test target: `net10.0`. + +Localized PoC validation: + +```text +dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Debug --filter "FullyQualifiedName~GeneratedMaterializerSpikeTests" +``` + +Result: + +- success; +- 2 tests passed. + +Mandatory validation: + +```text +dotnet restore .\Dapper.FluentMap.sln +dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore +dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build +dotnet pack .\Dapper.FluentMap.sln --configuration Release --no-build --output .\artifacts\packages +``` + +Results: + +- restore: success; +- build: success, 0 warnings, 0 errors; +- tests: success, 231 total tests passed: + - core: 200; + - Dommel: 7; + - analyzers: 9; + - generators: 14; + - generated-registration integration: 1; +- pack: success: + - `Dapper.FluentMap.2.0.0.nupkg`; + - `Dapper.FluentMap.Dommel.2.0.0.nupkg`; + - `Dapper.FluentMap.Analyzers.2.0.0.nupkg`; + - `Dapper.FluentMap.Generators.2.0.0.nupkg`. + +Known pack warnings: + +- `NU5125` for legacy `PackageLicenseUrl` in core and Dommel; +- NuGet README recommendation for core and Dommel. + +These warnings are pre-existing package metadata debt tracked outside this delivery. diff --git a/docs/sdd/etapa-6/README.md b/docs/sdd/etapa-6/README.md index dbd80ca..a2f4074 100644 --- a/docs/sdd/etapa-6/README.md +++ b/docs/sdd/etapa-6/README.md @@ -1,5 +1,7 @@ # Etapa 6 - Architectural Hardening +Etapa 6 Status: COMPLETED + ## Objective Formalizar contratos arquiteturais que reduzem ambiguidade sobre estado global, lifecycle de configuracao, integracao com Dapper e futuros caminhos de materializacao. @@ -13,14 +15,14 @@ Esta etapa preserva a compatibilidade publica existente do core `Dapper.FluentMa | 01 | Configuration Lifecycle | COMPLETED | Lifecycle suportado e mutacoes de runtime formalizados. | | 02 | Mapping State Encapsulation | COMPLETED | Snapshots read-only adicionados e superficie mutavel legada documentada. | | 03 | Dapper Compatibility Adapters | COMPLETED | Compatibility boundary interno adicionado; TypeHandler reflection isolada; ignored sentinel removido. | -| 04 | Generated Materializer Spike | NEXT | Investigar materializer gerado para `DbDataReader`. | +| 04 | Generated Materializer Spike | COMPLETED | Viabilidade tecnica confirmada com restricoes; generated + runtime fallback recomendado. | ## Delivery List -01 Configuration Lifecycle -> COMPLETED -02 Mapping State Encapsulation -> COMPLETED -03 Dapper Compatibility Adapters -> COMPLETED -04 Generated Materializer Spike -> NEXT +01 Configuration Lifecycle COMPLETED +02 Mapping State Encapsulation COMPLETED +03 Dapper Compatibility Adapters COMPLETED +04 Generated Materializer Spike COMPLETED ## Sources Of Truth @@ -33,8 +35,13 @@ Esta etapa preserva a compatibilidade publica existente do core `Dapper.FluentMa - `src/Dapper.FluentMap/FluentMapper.cs` - `src/Dapper.FluentMap/MappingRegistry.cs` -## Current Focus +## Results + +- [01 Configuration Lifecycle](01-configuration-lifecycle.md): formalizou configuracao em startup seguida de operacao read-only, preservando mutacoes legadas apenas sob quiescencia externa. +- [02 Mapping State Encapsulation](02-mapping-state-encapsulation.md): adicionou snapshots read-only e manteve campos publicos mutaveis como superficie legada. +- [03 Dapper Compatibility Adapters](03-dapper-compatibility-adapters.md): isolou detalhes Dapper-specific, centralizou TypeHandler reflection e removeu `IgnoredPropertyInfo`. +- [04 Generated Materializer Spike](04-generated-materializer-spike.md): concluiu `GO WITH CONSTRAINTS` para materializer gerado com fallback runtime. -Delivery 03 isolated Dapper compatibility details behind internal adapters. Residual TypeHandler reflection remains, but only in `DapperTypeHandlerAdapter`; `IgnoredPropertyInfo` was removed. +## Summary -Delivery 04 should investigate a generated `DbDataReader` materializer while preserving lifecycle, profiles, `MemberPath`, TypeHandler behavior and ignored mapping semantics. +Etapa 6 preservou compatibilidade publica e consolidou contratos para proximas mudancas de materializacao. O estado global ainda existe, mas o lifecycle foi documentado; leitura segura de estado recebeu snapshots; a compatibilidade com Dapper ficou atras de adapters internos; e o spike mostrou que um generated `DbDataReader` materializer e viavel para mappings estaticos, desde que coexista com fallback runtime para configuracao dinamica. diff --git a/docs/sdd/etapa-6/decisions.md b/docs/sdd/etapa-6/decisions.md index 5988a8d..cce29d9 100644 --- a/docs/sdd/etapa-6/decisions.md +++ b/docs/sdd/etapa-6/decisions.md @@ -125,3 +125,72 @@ Qualquer upgrade futuro de Dapper deve revisar explicitamente: - `SqlMapper.TypeHandlerCache.Parse(object)`; - comportamento de fallback quando um mapper retorna `null`; - testes `DapperCompatibilityAdapterTests`, `ValueObjectMaterializationTests`, `ConstructorMappingTests`, `NestedMaterializationSpikeTests`, `DapperIntegrationTests` e Dommel. + +## E6-D009 - Generated Materializer Direction + +O spike da Entrega 04 conclui `GO WITH CONSTRAINTS` para materializacao gerada. + +A arquitetura futura recomendada e: + +```text +QueryMapped + | + v +Generated materializer available and matching? + | yes + v +Generated materializer + | + no + v +Runtime NestedMaterializationPlan fallback +``` + +Um caminho generated-only foi rejeitado como arquitetura default porque quebraria configuracao dinamica, assembly scanning, conventions nao geraveis, maps em assemblies externos sem manifest e a superficie legada de mutacao publica ainda preservada por compatibilidade. + +## E6-D010 - Static Mapping Eligibility + +Materializers gerados devem ser usados apenas quando o generator conseguir provar estaticamente o mapping efetivo. + +Primeiro subconjunto elegivel: + +- maps declarados na compilacao atual; +- `Map(...).ToColumn("literal")`; +- `Ignore()`; +- `IncludeBase()` quando o base map tambem for geravel; +- profiles por `IProfileMap`; +- construtores publicos e setters publicos representaveis pelo symbol model. + +Devem cair para fallback runtime: + +- column names dinamicos; +- helper methods arbitrarios; +- assembly scanning; +- public dictionary mutation; +- conventions customizadas nao geraveis; +- naming policies aplicadas dinamicamente; +- maps de assemblies externos sem descriptor gerado. + +## E6-D011 - AOT Claims Require Runtime Evidence + +Generated materializers podem reduzir dependencia de `Expression.Compile`, `Activator` e reflection no hot path para casos estaticos, mas isso nao basta para declarar compatibilidade Native AOT. + +Qualquer etapa futura deve separar: + +- `Proven`: validado por build/publish/run; +- `Likely`: inferido de codigo gerado e analyzers; +- `Unknown`: dependente de Dapper, TypeHandlers, ambiente Native AOT ou configuracao dinamica. + +As annotations `RequiresUnreferencedCode` e `RequiresDynamicCode` das APIs que podem usar fallback runtime nao devem ser removidas ate haver um caminho publico que garanta generated-only sem fallback reflection-based. + +## E6-D012 - Generated TypeHandler Boundary + +TypeHandlers permanecem a area mais sensivel para materializer gerado. + +Codigo gerado nao deve espalhar reflection para internals do Dapper nem chamar APIs version-sensitive sem uma decisao propria. Uma etapa futura deve escolher entre: + +- uma pequena API/boundary publica no core para conversao gerada; +- chamada direta gerada a uma shape publica do Dapper, aceitando diagnostico/compile failure em upgrades; +- fallback runtime quando TypeHandler for necessario. + +A decisao E6-D006 permanece vigente ate essa escolha ser implementada e validada. diff --git a/docs/sdd/etapa-6/handoff.md b/docs/sdd/etapa-6/handoff.md index 0ffd6a8..e368363 100644 --- a/docs/sdd/etapa-6/handoff.md +++ b/docs/sdd/etapa-6/handoff.md @@ -1,264 +1,150 @@ # Etapa 6 Handoff -## Last Completed Delivery +## Etapa 6 Final State -03 - Dapper Compatibility Adapters +Etapa 6 esta `COMPLETED`. -## Current Architecture +O objetivo foi endurecer contratos arquiteturais antes de qualquer mudanca grande no materializer. A etapa preservou API publica, manteve `Dapper.FluentMap` em `netstandard2.0` e nao alterou Dommel funcionalmente. -`FluentMapper` remains a process-wide facade over static configuration state: +## Completed Deliveries -- static `MappingRegistry`; -- static `FluentMapConfiguration`; -- public mutable `EntityMaps`; -- public mutable `TypeConventions`; -- Dapper global type-map integration through `SqlMapper.SetTypeMap`. +01 Configuration Lifecycle - `COMPLETED` -Delivery 02 added read-only snapshot APIs: +- Contrato formal: `Configuration Phase -> Operational Phase`. +- Runtime reconfiguration continua possivel por compatibilidade, mas apenas sob quiescencia externa. +- Direct public dictionary mutation permanece superficie legada. -- `FluentMapper.GetEntityMaps()`; -- `FluentMapper.GetTypeConventions()`. +02 Mapping State Encapsulation - `COMPLETED` -These APIs return snapshot collections for inspection and do not expose the live `ConcurrentDictionary` instances or mutable convention lists. +- Adicionados snapshots read-only: + - `FluentMapper.GetEntityMaps()`; + - `FluentMapper.GetTypeConventions()`. +- Campos publicos mutaveis foram preservados por compatibilidade. -The supported lifecycle is: +03 Dapper Compatibility Adapters - `COMPLETED` -```text -Configuration Phase - | - v -Operational Phase -``` +- Criada fronteira interna `Dapper.FluentMap.Compatibility`. +- `DapperTypeHandlerAdapter` concentra reflection residual de TypeHandlers. +- `IgnoredPropertyInfo` foi removido. +- Ignored/nested mappings usam `DapperIgnoredMemberMap`. -Configuration should happen during startup or before first use of the affected types. Once queries begin, effective configuration should be treated as read-only. Runtime mutation through public APIs remains possible only as a compatibility behavior under external quiescence. +04 Generated Materializer Spike - `COMPLETED` -Profiles remain query-scoped through `QueryMapped()` and do not swap the Dapper global type map. +- Resultado: `GO WITH CONSTRAINTS`. +- Prototipo test-only validou materializer gerado conceitual para entidade simples, nested mutable object, immutable Value Object, profile e `DBNull`. +- Recomendacao: generated materializer com runtime fallback. -## Dapper Compatibility Boundary +## Architecture After Etapa 6 -Delivery 03 added an internal compatibility boundary in `Dapper.FluentMap.Compatibility`: +`FluentMapper` permanece uma fachada global: -- `DapperTypeHandlerAdapter`: centralizes residual reflection into `SqlMapper.TypeHandlerCache.Parse(object)`; -- `DapperFluentPropertyTypeMap`: exposes FluentMap property mappings to Dapper without using `CustomPropertyTypeMap`; -- `DapperPropertyMemberMap`: safe `SqlMapper.IMemberMap` for simple property mappings; -- `DapperIgnoredMemberMap`: safe `SqlMapper.IMemberMap` marker for ignored mappings and FluentMap-controlled nested paths. +- static `MappingRegistry`; +- static `FluentMapConfiguration`; +- public mutable `EntityMaps`; +- public mutable `TypeConventions`; +- Dapper global type-map integration por `SqlMapper.SetTypeMap`. -The intended shape is: +O lifecycle suportado e: ```text -FluentMap materialization/type maps - | - v -internal Dapper compatibility boundary +Configuration Phase | v -Dapper-specific behavior +Operational Phase ``` -`NestedMaterializationPlan` should not grow new direct reflection into Dapper internals. Future Dapper-specific workarounds should go through this compatibility boundary or a similarly explicit adapter. - -## Remaining Reflection Into Dapper Internals - -Residual reflection remains only for TypeHandler invocation in `DapperTypeHandlerAdapter`: +Profiles permanecem query-scoped: ```text -SqlMapper.TypeHandlerCache.Parse(object) -``` - -Dapper `2.1.79` exposes `SqlMapper.HasTypeHandler(type)` publicly, but no public API was found to convert a single `object` through the registered handler for an arbitrary target type. Because of that, `FM-RISK-007` is `MITIGATED`, not `RESOLVED`. - -If the expected Dapper shape is missing, the adapter throws `FluentMapConfigurationException` with an upgrade-oriented diagnostic instead of silently falling back to `Convert.ChangeType`. - -## Ignored Mapping Strategy - -`IgnoredPropertyInfo` was removed. - -Ignored root mappings and FluentMap-controlled nested paths now flow as: - -```text -DapperFluentPropertyTypeMap.GetMember(column) - | - v -DapperIgnoredMemberMap - | - v -MultiTypeMap returns null without consulting DefaultTypeMap +QueryMapped() ``` -This preserves the existing behavior that ignored/nested FluentMap mappings block Dapper fallback for that column, while removing the previous `PropertyInfo` sentinel whose members threw `NotImplementedException`. - -`FM-RISK-012` is `RESOLVED`. - -## Dapper Upgrade Checklist - -Before upgrading Dapper, review: - -- `SqlMapper.ITypeMap`; -- `SqlMapper.IMemberMap`; -- `DefaultTypeMap` constructor and member behavior; -- `SqlMapper.SetTypeMap` global behavior; -- `SqlMapper.HasTypeHandler`; -- `SqlMapper.TypeHandlerCache.Parse(object)`; -- fallback behavior when a mapper returns `null`; -- `DapperCompatibilityAdapterTests`; -- `ValueObjectMaterializationTests`; -- `NestedMaterializationSpikeTests`; -- `NestedObjectMaterializationTests`; -- `ConstructorMappingTests`; -- `DapperIntegrationTests`; -- Dommel tests. - -Do not update Dapper merely to test the adapter. Treat dependency upgrade as its own specification. - -## Materialization Architecture Relevant to Delivery 04 - -`QueryMapped*` still uses runtime `DbDataReader` materialization with cached `NestedMaterializationPlan`. - -The plan remains responsible for: - -- resolving the effective map by entity, optional profile and column shape; -- preserving full `MemberPath` identity; -- deciding nested/null subtree behavior; -- invoking constructors for immutable objects and Value Objects; -- applying Dapper TypeHandlers for scalar mapped properties through `DapperTypeHandlerAdapter`; -- falling back to local conversion when no handler is registered. - -`Dapper.Query` remains Dapper-owned and should continue to use the default type map installed by `SqlMapper.SetTypeMap`. - -## Constraints the Generated Materializer Spike Must Preserve - -Delivery 04 must preserve: - -- explicit mapping before convention/naming policy before Dapper default; -- query-scoped profiles without temporary `SqlMapper.SetTypeMap` mutation; -- `MemberPath` identity for same-terminal nested paths; -- ignored mappings blocking Dapper/default fallback for their configured columns; -- TypeHandler behavior for scalar Value Object properties; -- nullable TypeHandler null semantics; -- diagnostic failure when Dapper compatibility internals are invalid; -- configuration lifecycle from Delivery 01; -- read-only snapshot behavior from Delivery 02; -- no public API additions unless the generated materializer specification explicitly justifies them; -- no Dommel redesign unless core changes require it. - -## Decisions That Must Be Preserved - -- E6-D001 - Configuration lifecycle is startup configuration followed by read-only operation. -- E6-D002 - Delivery 01 chose Documentation Contract Only; no `Freeze()`, no sealing API and no runtime enforcement. -- E6-D003 - Profiles remain query-scoped and must not be implemented by temporary `SqlMapper.SetTypeMap` mutation. -- E6-D004 - Mapping state read-only snapshots are the minor-compatible encapsulation path; mutable public fields remain legacy compatibility surface. -- E6-D005 - Dapper compatibility details are isolated behind internal adapters. -- E6-D006 - Residual TypeHandler reflection remains isolated and diagnostic. -- E6-D007 - Ignored mappings use safe `IMemberMap` markers, not throwing `PropertyInfo` sentinels. -- E6-D008 - Dapper upgrades require targeted compatibility review. +Eles nao trocam `SqlMapper.SetTypeMap` temporariamente. -## Mapping State After Delivery 03 - -Official mutation paths still go through `FluentMapConfiguration` and `MappingRegistry`: +`QueryMapped*` ainda usa `NestedMaterializationPlan` runtime/reflection-based. O futuro caminho recomendado e: ```text -Consumer API - | - v -FluentMapper / FluentMapConfiguration - | - v -MappingRegistry - | - v -Validation - | - v -Cache invalidation - | - v -Dapper integration +QueryMapped + | + v +Generated materializer matches? + | yes + v +Generated path + | + no + v +Runtime fallback ``` -Read-only inspection should use `GetEntityMaps()` and `GetTypeConventions()`. These are snapshots, so later registrations do not mutate a previously returned view. - -## Public Compatibility Surfaces Still Present - -- `FluentMapper.EntityMaps` remains `public static readonly ConcurrentDictionary`. -- `FluentMapper.TypeConventions` remains `public static readonly ConcurrentDictionary>`. -- `IEntityMap.PropertyMaps` remains mutable. -- `Convention.PropertyMaps` and `Convention.ConventionConfigurations` remain mutable. -- Direct mutation through these surfaces remains possible and can bypass registry invariants. - -## Registry Invariants - -- Official `AddMap(...)` validates before storage. -- Official `AddProfile()` validates before storage. -- Official convention/naming-policy registration validates before storage. -- Duplicate default maps and duplicate profiles remain rejected by the registry. -- Profiles remain stored in `ProfileMaps[(EntityType, ProfileType)]` and are not exposed by `GetEntityMaps()`. - -## Cache Invariants - -- Official default map registration invalidates property-map and materialization-plan cache entries for the entity type and reinstalls the Dapper type map. -- Official profile registration invalidates caches for the entity type and does not call `SqlMapper.SetTypeMap`. -- Official convention/naming-policy registration invalidates caches for the entity type and reinstalls the Dapper type map. -- Direct mutation of legacy public dictionaries does not invalidate caches. - -## Remaining Dapper-Specific Technical Debt - -- `SqlMapper.SetTypeMap` remains process-global state. -- Dommel still reads public legacy mapping dictionaries directly. -- Profiles are not supported through `Dapper.Query()` or Dapper multi-mapping. -- The runtime materializer still uses reflection/dynamic-code paths for `QueryMapped*`. -- Residual TypeHandler invocation still reflects into `SqlMapper.TypeHandlerCache.Parse(object)`, isolated by `DapperTypeHandlerAdapter`. - -## Files Changed In Delivery 03 - -- `README.md` -- `docs/sdd/fluentmap-risk-assessment.md` -- `docs/sdd/etapa-6/README.md` -- `docs/sdd/etapa-6/decisions.md` -- `docs/sdd/etapa-6/handoff.md` -- `docs/sdd/etapa-6/03-dapper-compatibility-adapters.md` -- `src/Dapper.FluentMap/Compatibility/DapperTypeHandlerAdapter.cs` -- `src/Dapper.FluentMap/Compatibility/DapperFluentPropertyTypeMap.cs` -- `src/Dapper.FluentMap/Compatibility/DapperPropertyMemberMap.cs` -- `src/Dapper.FluentMap/Compatibility/DapperIgnoredMemberMap.cs` -- `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs` -- `src/Dapper.FluentMap/MappingRegistry.cs` -- `src/Dapper.FluentMap/TypeMaps/FluentTypeMap.cs` -- `src/Dapper.FluentMap/TypeMaps/FluentMapTypeMap.cs` -- `src/Dapper.FluentMap/TypeMaps/FluentConventionTypeMap.cs` -- `src/Dapper.FluentMap/TypeMaps/MultiTypeMap.cs` -- `src/Dapper.FluentMap/TypeMaps/IgnoredPropertyInfo.cs` removed -- `test/Dapper.FluentMap.Tests/DapperCompatibilityAdapterTests.cs` - -## Public API Impact - -Delivery 03 added no public API and removed no public API. - -The internal `IgnoredPropertyInfo` implementation detail was removed. FluentMap public behavior for ignored mappings, nested paths, Dapper fallback, TypeHandlers and profiles is preserved. - ## Remaining Risks -- FM-RISK-001 remains mitigated, not resolved: global FluentMap/Dapper state still exists. -- FM-RISK-002 remains open with mitigation: read-only snapshots now exist, but public mutable dictionaries can still bypass registry validation/cache invalidation. -- FM-RISK-007 remains mitigated, not resolved: TypeHandler invocation still reflects into Dapper internals, but only through `DapperTypeHandlerAdapter`. -- FM-RISK-012 is resolved: `IgnoredPropertyInfo` no longer exists. -- Test assemblies still disable parallelization because of global state. -- There is still no immutable snapshot registry. -- There is still no runtime enforcement of the lifecycle boundary. - -## Preconditions for Delivery 04 - -- Read `docs/sdd/etapa-6/01-configuration-lifecycle.md`, `docs/sdd/etapa-6/02-mapping-state-encapsulation.md`, `docs/sdd/etapa-6/03-dapper-compatibility-adapters.md` and `docs/sdd/etapa-6/decisions.md`. -- Preserve source/binary compatibility unless a future major-version plan is explicit. -- Treat public dictionaries as compatibility debt, not as implementation detail that can be removed. -- Use existing tests in `ConfigurationLifecycleTests`, `MappingRegistryTests`, `DiagnosticsApiTests`, `MappingProfileTests`, `DapperCompatibilityAdapterTests`, `ValueObjectMaterializationTests` and `DapperIntegrationTests` as baseline. -- Keep Dommel out of scope unless a core change provably requires review. - -## Things Delivery 04 Must Not Assume - -- Do not assume `Initialize(...)` is currently one-shot. -- Do not assume runtime mutation can be forbidden in a minor-compatible change. -- Do not assume public dictionary mutation triggers registry validation, cache invalidation or `SqlMapper.SetTypeMap`. -- Do not assume profiles are visible to `Dapper.Query()` or Dommel. -- Do not assume test parallelization can be re-enabled before global state is encapsulated or isolated. -- Do not add a freeze/seal API without a compatibility and migration decision. -- Do not add new direct reflection into Dapper internals outside the compatibility boundary. +- `FM-RISK-001`: global FluentMap/Dapper state permanece mitigado, nao resolvido. +- `FM-RISK-002`: public mutable dictionaries ainda podem bypassar registry/cache. +- `FM-RISK-004`: materializer gerado ainda nao existe em runtime de producao. +- `FM-RISK-005`: `QueryMapped*` ainda bufferiza todas as linhas. +- `FM-RISK-006`: factory methods/private constructors/private setters/fields/NRT continuam fora do contrato. +- `FM-RISK-007`: TypeHandler invocation ainda depende de reflection isolada. +- `FM-RISK-008`: conventions/naming policies por profile ainda nao existem. +- `FM-RISK-009`: profiles ainda nao se aplicam a `Dapper.Query` ou multi-mapping. + +## Resolved Risks + +- `FM-RISK-012`: `IgnoredPropertyInfo` foi removido; ignored/nested mappings usam marker seguro. + +## Mitigated Risks + +- `FM-RISK-001`: lifecycle documentado e testado. +- `FM-RISK-003`: scanning marcado/documentado como trimming-sensitive; explicit/generated registration permanecem caminhos preferidos. +- `FM-RISK-004`: spike adicionou evidencia tecnica e arquitetura recomendada, mas nao resolveu o runtime. +- `FM-RISK-007`: reflection Dapper-specific isolada em `DapperTypeHandlerAdapter`. +- `FM-RISK-014`: analyzer/generator permanecem complementares a validacao runtime. + +## Decisions That Future Work Must Preserve + +- E6-D001 - Configuration lifecycle contract. +- E6-D002 - Documentation contract only for Delivery 01; sem freeze/seal API. +- E6-D003 - Profiles remain query-scoped. +- E6-D004 - Mapping state read-only snapshots. +- E6-D005 - Dapper compatibility boundary. +- E6-D006 - Residual TypeHandler reflection isolated and diagnostic. +- E6-D007 - Ignored mapping without throwing `PropertyInfo` sentinel. +- E6-D008 - Dapper upgrade checklist. +- E6-D009 - Generated materializer direction: generated + runtime fallback. +- E6-D010 - Static mapping eligibility. +- E6-D011 - AOT claims require runtime evidence. +- E6-D012 - Generated TypeHandler boundary. + +## Recommended Next Stage + +Etapa 7 - Generated Materialization + +Suggested sequence: + +1. Generated materializer contract and runtime lookup. +2. Static mapping DSL discovery in the generator. +3. Generated row materializer for explicit maps, nested mutable objects, immutable constructors and `DBNull`. +4. Generated profile support and diagnostics. +5. TypeHandler/conversion strategy. +6. Trim, Native AOT and performance validation. + +## Preconditions + +- Preserve source/binary compatibility. +- Keep runtime fallback. +- Keep `Dapper.Query` default behavior unchanged. +- Keep profiles query-scoped and avoid `SqlMapper.SetTypeMap` mutation scopes. +- Do not require generator installation for existing consumers. +- Do not remove RUC/RDC annotations while runtime fallback remains possible. +- Validate with generator tests, integration tests, trimmed smoke and Native AOT runtime when environment supports it. + +## Open Questions + +- What minimal public or internal contract should connect generated materializers to the core lookup? +- How should generated descriptors prove they still match effective runtime configuration when public dictionaries can be mutated directly? +- Should first generated support include built-in naming policies or explicit maps only? +- What is the safest TypeHandler strategy without spreading Dapper-internal reflection? +- How should maps in referenced assemblies expose generated materializer manifests? +- What diagnostics should explain generated path vs fallback? +- What benchmark shape should become the baseline for startup, first query, throughput, allocation and memory? +- Which environment will validate Native AOT runtime with the required platform linker C++ installed? diff --git a/docs/sdd/fluentmap-risk-assessment.md b/docs/sdd/fluentmap-risk-assessment.md index 16d607d..6e88a13 100644 --- a/docs/sdd/fluentmap-risk-assessment.md +++ b/docs/sdd/fluentmap-risk-assessment.md @@ -265,6 +265,8 @@ Nested materialization, Value Object construction and profiles are implemented t - `docs/sdd/etapa-5/README.md`: P1 item to create a generated `DbDataReader` materializer. - `src/Dapper.FluentMap/QueryMappedExtensions.cs`: all public `QueryMapped*` methods are annotated with `RequiresUnreferencedCode` and `RequiresDynamicCode`. - `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs`: compiles delegates for constructors, getters, setters and converters. +- `docs/sdd/etapa-6/04-generated-materializer-spike.md`: concludes `GO WITH CONSTRAINTS` for generated materializers with runtime fallback. +- `test/Dapper.FluentMap.Tests/GeneratedMaterializerSpikeTests.cs`: test-only prototype proves direct `IDataRecord` materialization for simple entity, explicit mapping, nested mutable object, immutable Value Object, profile and `DBNull` without runtime expression compilation in the prototype. ### Cenario de impacto @@ -284,7 +286,7 @@ Use explicit/generated registration for startup mapping and avoid `QueryMapped*` ### Recomendacao -Prioritize a generated materializer for `DbDataReader` that covers nested paths, Value Objects and profiles without expression compilation in the hot path. Keep runtime `QueryMapped*` as fallback for dynamic configurations. +Implement generated `DbDataReader` materialization only as an opt-in/generated path with runtime fallback. Start with statically provable explicit/profile maps and keep `QueryMapped*` runtime annotations until trimmed and Native AOT runtime validation proves a generated-only public path. ### Relacoes diff --git a/test/Dapper.FluentMap.Tests/GeneratedMaterializerSpikeTests.cs b/test/Dapper.FluentMap.Tests/GeneratedMaterializerSpikeTests.cs new file mode 100644 index 0000000..4b8ddfc --- /dev/null +++ b/test/Dapper.FluentMap.Tests/GeneratedMaterializerSpikeTests.cs @@ -0,0 +1,162 @@ +using System; +using System.Data; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class GeneratedMaterializerSpikeTests + { + [Fact] + public void GeneratedLikeMaterializerShouldMaterializeSimpleEntityWithNestedMutableObjectAndDbNull() + { + using (var reader = CreateReader( + new[] { "customer_id", "full_name", "city", "note" }, + new object[] { 1, "Ada Lovelace", "London", DBNull.Value }, + new object[] { 2, "Grace Hopper", DBNull.Value, "compiler" })) + { + Assert.True(reader.Read()); + var first = GeneratedCustomerMaterializer.ReadDefault(reader); + + Assert.Equal(1, first.Id); + Assert.Equal("Ada Lovelace", first.Name); + Assert.NotNull(first.Address); + Assert.Equal("London", first.Address.City); + Assert.Null(first.Note); + + Assert.True(reader.Read()); + var second = GeneratedCustomerMaterializer.ReadDefault(reader); + + Assert.Equal(2, second.Id); + Assert.Equal("Grace Hopper", second.Name); + Assert.Null(second.Address); + Assert.Equal("compiler", second.Note); + } + } + + [Fact] + public void GeneratedLikeMaterializerShouldSupportImmutableValueObjectConstructorAndProfiles() + { + using (var reader = CreateReader( + new[] { "legacy_id", "legacy_cpf", "legal_name" }, + new object[] { 7, "12345678909", "Legacy Ada" }, + new object[] { 8, DBNull.Value, "Legacy Grace" })) + { + Assert.True(reader.Read()); + var first = GeneratedCustomerMaterializer.ReadLegacyProfile(reader); + + Assert.Equal(7, first.Id); + Assert.Equal("Legacy Ada", first.Name); + Assert.NotNull(first.Cpf); + Assert.Equal("12345678909", first.Cpf.Number); + + Assert.True(reader.Read()); + var second = GeneratedCustomerMaterializer.ReadLegacyProfile(reader); + + Assert.Equal(8, second.Id); + Assert.Equal("Legacy Grace", second.Name); + Assert.Null(second.Cpf); + } + } + + private static IDataReader CreateReader(string[] columns, params object[][] rows) + { + var table = new DataTable(); + foreach (var column in columns) + { + table.Columns.Add(column, typeof(object)); + } + + foreach (var row in rows) + { + table.Rows.Add(row); + } + + return table.CreateDataReader(); + } + + private static class GeneratedCustomerMaterializer + { + internal static GeneratedCustomer ReadDefault(IDataRecord record) + { + var customer = new GeneratedCustomer + { + Id = ReadInt32(record, 0), + Name = ReadString(record, 1), + Note = ReadString(record, 3) + }; + + if (!record.IsDBNull(2)) + { + customer.Address = new GeneratedAddress + { + City = ReadString(record, 2) + }; + } + + return customer; + } + + internal static GeneratedCustomer ReadLegacyProfile(IDataRecord record) + { + return new GeneratedCustomer( + ReadInt32(record, 0), + record.IsDBNull(1) ? null : new GeneratedCpf(ReadString(record, 1)), + ReadString(record, 2)); + } + + private static int ReadInt32(IDataRecord record, int ordinal) + { + return record.IsDBNull(ordinal) ? default : Convert.ToInt32(record.GetValue(ordinal)); + } + + private static string ReadString(IDataRecord record, int ordinal) + { + return record.IsDBNull(ordinal) ? null : Convert.ToString(record.GetValue(ordinal)); + } + } + + private sealed class GeneratedCustomer + { + public GeneratedCustomer() + { + } + + public GeneratedCustomer(int id, GeneratedCpf cpf, string name) + { + Id = id; + Cpf = cpf; + Name = name; + } + + public int Id { get; set; } + + public string Name { get; set; } + + public string Note { get; set; } + + public GeneratedAddress Address { get; set; } + + public GeneratedCpf Cpf { get; } + } + + private sealed class GeneratedAddress + { + public string City { get; set; } + } + + private sealed class GeneratedCpf + { + public GeneratedCpf(string number) + { + if (string.IsNullOrWhiteSpace(number)) + { + throw new ArgumentException("CPF cannot be empty.", nameof(number)); + } + + Number = number; + } + + public string Number { get; } + } + } +} From 4025fbf5d5a32e927d419c5b9ea1a8fc48c791f6 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sun, 26 Jul 2026 19:59:53 -0300 Subject: [PATCH 19/20] docs(fluentmap): refresh public documentation --- README.md | 886 +++++++++++++++++----- src/Dapper.FluentMap.Analyzers/README.md | 6 + src/Dapper.FluentMap.Generators/README.md | 15 + 3 files changed, 717 insertions(+), 190 deletions(-) diff --git a/README.md b/README.md index 2f32d7f..68dee29 100644 --- a/README.md +++ b/README.md @@ -1,333 +1,839 @@ -## 📦 Archived -This repository is archived as I'm not using this library myself anymore and have no time maintaining it. Thanks for using it. +# FluentMap -
+[Português (Brasil)](#português-brasil) +FluentMap provides a fluent API for mapping .NET object properties to database columns used by [Dapper](https://github.com/DapperLib/Dapper), keeping persistence attributes out of your POCOs. -# Dapper.FluentMap -Provides a simple API to fluently map POCO properties to database columns when using Dapper. +> This repository originated from the archived `Dapper.FluentMap` project and is being evolved in this fork. Some legacy project metadata still reflects the original package history. -
+## Why FluentMap? -| Windows | Linux/OSX | NuGet | -| --- | --- | --- | -| [![Windows Build status](https://ci.appveyor.com/api/projects/status/x6grw3cjuyud9c76?svg=true)](https://ci.appveyor.com/project/henkmollema/dapper-fluentmap) | [![Linux Build Status](https://travis-ci.org/henkmollema/Dapper-FluentMap.svg?branch=master)](https://travis-ci.org/henkmollema/Dapper-FluentMap) | [![NuGet Version](http://img.shields.io/nuget/v/Dapper.FluentMap.svg)](https://www.nuget.org/packages/Dapper.FluentMap/ "NuGet version") | +Dapper maps columns to members by name. FluentMap is useful when your database shape does not match your domain model, or when you want the mapping rules to live outside the model classes. -### Introduction +Use FluentMap to: -This [Dapper](https://github.com/StackExchange/dapper-dot-net) extension allows you to fluently configure the mapping between POCO properties and database columns. This keeps your POCO's clean of mapping attributes. The functionality is similar to [Entity Framework Fluent API](http://msdn.microsoft.com/nl-nl/data/jj591617.aspx). If you have any questions, suggestions or bugs, please don't hesitate to [contact me](mailto:henkmollema@gmail.com) or create an issue. +- map properties to columns explicitly; +- ignore mapped properties; +- apply naming conventions or naming policies; +- compose explicit maps, inherited maps and conventions; +- inspect and validate configuration; +- opt into FluentMap-controlled materialization for nested objects, immutable types, value objects and mapping profiles. -
+## Installation -### Download -[![Download Dapper.FluentMap on NuGet](http://i.imgur.com/Rs483do.png "Download Dapper.FluentMap on NuGet")](https://www.nuget.org/packages/Dapper.FluentMap) +Install the package that matches the functionality you need: -
+| Package | Purpose | +|---|---| +| `Dapper.FluentMap` | Core mapping API and Dapper integration. | +| `Dapper.FluentMap.Dommel` | Optional Dommel integration for table, key and generated-column mapping. | +| `Dapper.FluentMap.Analyzers` | Roslyn analyzers for statically provable configuration mistakes. | +| `Dapper.FluentMap.Generators` | Source generator for build-time map registration. | + +```powershell +Install-Package Dapper.FluentMap +``` + +or: + +```bash +dotnet add package Dapper.FluentMap +``` + +The core package targets `netstandard2.0` and depends on Dapper. + +## Quick Start -### Usage -#### Manual mapping -You can map property names manually using the [`EntityMap`](https://github.com/henkmollema/Dapper-FluentMap/blob/master/src/Dapper.FluentMap/Mapping/EntityMap.cs) class. When creating a derived class, the constructor gives you access to the `Map` method, allowing you to specify to which database column name a certain property of `TEntity` should map to. ```csharp -public class ProductMap : EntityMap +using Dapper; +using Dapper.FluentMap; +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public int Id { get; set; } + + public string Name { get; set; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + } +} + +FluentMapper.Initialize(config => +{ + config.AddMap(new CustomerMap()); +}); + +var customer = connection.QuerySingle( + "SELECT 7 AS customer_id, 'Ada' AS Name;"); +``` + +Call `FluentMapper.Initialize(...)` during application startup and treat the effective configuration as read-only once queries begin. + +## Mapping + +Create a map by deriving from `EntityMap`: + +```csharp +public sealed class ProductMap : EntityMap { public ProductMap() { - // Map property 'Name' to column 'strName'. - Map(p => p.Name) - .ToColumn("strName"); + Map(product => product.Id).ToColumn("product_id"); + Map(product => product.Name).ToColumn("product_name", caseSensitive: false); + Map(product => product.LastModified).Ignore(); + } +} +``` + +Explicit mappings take precedence over convention mappings. Unmapped members fall back to Dapper's normal behavior. + +Inherited explicit mappings can be included when the derived entity should reuse a base entity map: + +```csharp +public sealed class PreferredCustomerMap : EntityMap +{ + public PreferredCustomerMap() + { + IncludeBase(); + Map(customer => customer.Tier).ToColumn("tier"); + } +} +``` + +Register the base map before the derived map. + +## Configuration - // Ignore the 'LastModified' property when mapping. - Map(p => p.LastModified) - .Ignore(); +Register maps explicitly: + +```csharp +FluentMapper.Initialize(config => +{ + config.AddMap(); + config.AddMap(); +}); +``` + +Assembly scanning is available for normal runtime scenarios: + +```csharp +FluentMapper.Initialize(config => +{ + config.AddMapsFromAssemblyContaining(); + config.AddMapsFromAssembly(typeof(CustomerMap).Assembly, "App.Domain.Maps"); +}); +``` + +Use explicit registration for trimmed or Native AOT applications. + +You can validate the current configuration after registration: + +```csharp +FluentMapper.Initialize(config => config.AddMap()); +FluentMapper.Validate(); +``` + +For read-only inspection, use `FluentMapper.GetEntityMaps()` and `FluentMapper.GetTypeConventions()`. The public mutable dictionaries `FluentMapper.EntityMaps` and `FluentMapper.TypeConventions` remain for compatibility, but new code should prefer the registration APIs. + +## Conventions and Naming Policies + +Conventions let you map repeated column patterns: + +```csharp +using Dapper.FluentMap.Conventions; + +public sealed class PrefixConvention : Convention +{ + public PrefixConvention() + { + Properties() + .Configure(property => property.HasPrefix("col")); } } + +FluentMapper.Initialize(config => +{ + config.AddConvention() + .ForEntity(); +}); ``` -Column names are mapped case sensitive by default. You can change this by specifying the `caseSensitive` parameter in the `ToColumn()` method: `Map(p => p.Name).ToColumn("strName", caseSensitive: false)`. +Naming policies cover common name transformations: -#### Nested object materialization -Nested paths can be configured with the same `Map(...)` API, but materializing the object graph is opt-in. Use `QueryMapped()` or `QueryMappedSingle()` when you want FluentMap to create supported intermediate objects or constructor-based immutable value objects: +```csharp +using Dapper.FluentMap.Naming; + +FluentMapper.Initialize(config => +{ + config.UseNamingPolicy(NamingPolicy.SnakeCase, caseSensitive: false) + .ForEntity(); +}); +``` + +Available policies include `Identity`, `SnakeCase`, `Prefix(...)`, `Suffix(...)`, `Custom(...)` and composition with `Then(...)`, `WithPrefix(...)` and `WithSuffix(...)`. + +## Immutable Types and Constructor Mapping + +FluentMap participates in Dapper constructor mapping for root-level explicit mappings: ```csharp -public class CustomerMap : EntityMap +public sealed class Customer +{ + public Customer(int id, string fullName) + { + Id = id; + FullName = fullName; + } + + public int Id { get; } + + public string FullName { get; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.FullName).ToColumn("full_name"); + } +} +``` + +When you need FluentMap to build nested immutable objects or value objects, use `QueryMapped*`. + +## Nested Object Mapping + +Nested member paths can be configured with the same `Map(...)` API: + +```csharp +public sealed class CustomerMap : EntityMap { public CustomerMap() { - Map(c => c.Address.City) - .ToColumn("city"); + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Address.City).ToColumn("city"); } } +``` +Use FluentMap's opt-in query helpers to materialize nested object graphs: + +```csharp var customer = connection.QueryMappedSingle( - "SELECT 'Sao Paulo' AS city"); + "SELECT 7 AS customer_id, 'Sao Paulo' AS city;"); +``` + +`QueryMapped*` creates supported intermediate objects, preserves null semantics for nested subtrees and rejects unsupported paths with `FluentMapConfigurationException`. + +## Value Objects + +For scalar value objects mapped as a whole property, prefer a Dapper `TypeHandler`: + +```csharp +Map(customer => customer.Cpf).ToColumn("cpf"); ``` -Constructor-based Value Objects are supported when each mapped component can be bound to a public constructor parameter: +For value objects mapped through their components, `QueryMapped*` can construct them through matching public constructors: ```csharp public sealed class CustomerMap : EntityMap { public CustomerMap() { - Map(c => c.Id).ToColumn("id"); - Map(c => c.Cpf.Number).ToColumn("cpf"); + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Cpf.Number).ToColumn("cpf"); } } var customer = connection.QueryMappedSingle( - "SELECT 1 AS id, '12345678909' AS cpf"); + "SELECT 1 AS customer_id, '12345678909' AS cpf;"); ``` -The regular `Dapper.Query()` path continues to handle root properties, conventions, constructor mapping, TypeHandlers and Dapper fallback as before. For scalar Value Objects mapped as a whole, such as `Map(c => c.Cpf).ToColumn("cpf")`, prefer a Dapper `TypeHandler`. For nested paths such as `Map(c => c.Cpf.Number)`, `QueryMapped*` constructs the Value Object through public constructors and preserves domain invariants. Factory methods and generated materializers are not part of this runtime path; the generated materializer direction is documented as a future architecture spike, not a production feature. +Factory methods are not used by the current runtime materializer. -#### Mapping profiles -When the same entity needs different SQL shapes, register an opt-in mapping profile and select it explicitly per query. Profiles do not replace the Dapper type map global for the entity. +## Mapping Profiles + +Profiles are opt-in mappings for the same entity under different SQL shapes: ```csharp -public sealed class LegacyCustomerProfile : IMappingProfile +using Dapper.FluentMap.Mapping; + +public sealed class LegacyProfile : IMappingProfile { } public sealed class LegacyCustomerMap : EntityMap, - IProfileMap + IProfileMap { public LegacyCustomerMap() { - Map(c => c.Id).ToColumn("id"); - Map(c => c.Name).ToColumn("legal_name"); + Map(customer => customer.Id).ToColumn("id"); + Map(customer => customer.Name).ToColumn("legal_name"); } } FluentMapper.Initialize(config => - { - config.AddMap(); - config.AddProfile(); - }); +{ + config.AddMap(); + config.AddProfile(); +}); -var legacyCustomer = connection.QueryMappedSingle( - "SELECT id, legal_name FROM legacy_customer"); +var legacy = connection.QueryMappedSingle( + "SELECT 7 AS id, 'Legacy Ltd.' AS legal_name;"); ``` -`connection.Query(...)` and `connection.QueryMapped(...)` continue using the default mapping. Profile selection is tied to the `QueryMapped()` operation, so concurrent queries using different profiles do not mutate `SqlMapper.SetTypeMap`. +Profiles are selected per `QueryMapped()` operation. They do not replace the global Dapper type map for the entity. + +## Diagnostics -#### Configuration lifecycle -FluentMap configuration is process-wide because it stores mappings in a global registry and installs default mappings in Dapper's global type-map registry. The supported lifecycle is: +Use runtime validation to fail fast after configuration: -```text -Configuration Phase - | - v -Operational Phase +```csharp +FluentMapper.Validate(); ``` -Configure FluentMap during application startup, optionally call `FluentMapper.Validate()`, then treat the effective configuration as read-only once queries begin. `FluentMapper.Initialize(...)` can still be called more than once for additive configuration, subject to the existing duplicate-map validations, but runtime reconfiguration is not a concurrency contract. +Use `Explain()` or `Explain()` to inspect the effective mapping: -For compatibility, the public registration APIs still mutate the global registry immediately. If an application changes mappings after queries have started, it must guarantee external quiescence for the affected types: no concurrent queries, no active materializers, and no competing `SqlMapper.SetTypeMap` changes. Direct mutation of `FluentMapper.EntityMaps` or `FluentMapper.TypeConventions` is a legacy compatibility surface and can bypass validation, cache invalidation and Dapper type-map installation; prefer `Initialize(...)` and the fluent registration APIs. For read-only inspection, prefer `FluentMapper.GetEntityMaps()` and `FluentMapper.GetTypeConventions()` snapshots. +```csharp +var explanation = FluentMapper.Explain(); + +foreach (var member in explanation.Members) +{ + Console.WriteLine($"{member.MemberPath} -> {member.ColumnName} ({member.Source})"); +} +``` + +## Source Generator and Analyzers + +`Dapper.FluentMap.Analyzers` reports configuration mistakes that can be proven at compile time, such as invalid map expressions, duplicate member paths, duplicate columns and invalid profile registrations. It complements runtime validation and does not execute map constructors or scan assemblies. + +`Dapper.FluentMap.Generators` discovers eligible `IEntityMap` implementations in the current compilation and emits `AddGeneratedMappings()`: -**Initialization:** ```csharp FluentMapper.Initialize(config => +{ + config.AddGeneratedMappings(); +}); +``` + +Generated registration calls the existing `AddMap()` / `AddProfile()` paths. It does not generate database materializers, scan referenced assemblies or replace `FluentMapper.Validate()`. + +## Trimming / Native AOT + +FluentMap has different levels of support depending on the API: + +| API area | Trimming / Native AOT status | +|---|---| +| Explicit `AddMap()` registration | Preferred path for trimmed and Native AOT applications. | +| Generated registration | Useful alternative to assembly scanning for maps in the current compilation. | +| Assembly scanning APIs | Reflection-discovery based and annotated as trimming-sensitive. | +| `QueryMapped*` | Runtime reflection and dynamic-code based; annotated with trimming and dynamic-code warnings. | + +Do not treat the package as fully Native AOT safe just because explicit registration works. Prefer explicit or generated registration and avoid reflection scanning in trimmed applications. + +## Dapper Integration + +FluentMap installs Dapper type maps for configured entities. The normal Dapper APIs continue to be the default path for root-level mapping: + +```csharp +connection.Query(sql); +connection.QuerySingle(sql); +``` + +Use FluentMap query helpers when you need FluentMap-controlled advanced materialization: + +```csharp +connection.QueryMapped(sql); +connection.QueryMappedSingle(sql); +connection.QueryMappedSingle(sql); +``` + +`QueryMapped*` returns buffered results and is the path that supports nested object materialization, constructor-built value objects and profile-specific mapping. + +## Dommel + +Install `Dapper.FluentMap.Dommel` when using [Dommel](https://github.com/henkmollema/Dommel): + +```bash +dotnet add package Dapper.FluentMap.Dommel +``` + +Create maps with `DommelEntityMap` when you need Dommel-specific table and key metadata: + +```csharp +using Dapper.FluentMap.Dommel.Mapping; +using Dapper.FluentMap.Dommel; + +public sealed class ProductMap : DommelEntityMap +{ + public ProductMap() { - config.AddMap(new ProductMap()); - }); + ToTable("products"); + Map(product => product.Id).ToColumn("product_id").IsKey().IsIdentity(); + } +} ``` -You can also register map types directly when they have a public parameterless constructor: +Enable Dommel integration during FluentMap configuration: + ```csharp FluentMapper.Initialize(config => - { - config - .AddMap() - .AddMap(); - }); +{ + config.AddMap(new ProductMap()); + config.ForDommel(); +}); +``` + +## Current Limitations + +- FluentMap configuration is process-wide. Configure at startup and avoid changing mappings while queries are running. +- Assembly scanning depends on reflection discovery and is not the recommended path for trimmed or Native AOT applications. +- `QueryMapped*` uses runtime metadata and dynamic code; it is not the Native AOT-safe materialization path. +- Mapping profiles are selected only through `QueryMapped()` APIs. +- `QueryMapped*` is buffered; it does not expose unbuffered streaming. +- Value object construction uses matching public constructors, not factory methods. + +## Contributing + +Keep changes small, compatible with the public API and covered by focused tests. The core library should remain a FluentMap layer for Dapper, not an ORM, SQL generator or CRUD abstraction. + +Typical validation: + +```bash +dotnet restore ./Dapper.FluentMap.sln +dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore +dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build ``` -Assembly scanning is available as a convenience, while explicit `AddMap()` registration remains the path that does not require scanning: +## License + +FluentMap is licensed under the [MIT License](LICENSE). + +--- + +# Português (Brasil) + +[Back to English](#fluentmap) + +FluentMap fornece uma API fluente para mapear propriedades de objetos .NET para colunas de banco de dados usadas pelo [Dapper](https://github.com/DapperLib/Dapper), mantendo atributos de persistência fora dos seus POCOs. + +> Este repositório se originou do projeto arquivado `Dapper.FluentMap` e está sendo evoluído neste fork. Alguns metadados legados ainda refletem o histórico do pacote original. + +## Por Que FluentMap? + +O Dapper mapeia colunas para membros pelo nome. FluentMap é útil quando o formato do banco não combina com o modelo de domínio, ou quando você quer manter as regras de mapeamento fora das classes do modelo. + +Use FluentMap para: + +- mapear propriedades para colunas explicitamente; +- ignorar propriedades mapeadas; +- aplicar convenções ou políticas de nomenclatura; +- compor mapas explícitos, mapas herdados e convenções; +- inspecionar e validar a configuração; +- optar por materialização controlada pelo FluentMap para objetos aninhados, tipos imutáveis, Value Objects e profiles de mapeamento. + +## Instalação + +Instale o pacote conforme a funcionalidade necessária: + +| Pacote | Finalidade | +|---|---| +| `Dapper.FluentMap` | API principal de mapeamento e integração com Dapper. | +| `Dapper.FluentMap.Dommel` | Integração opcional com Dommel para tabela, chave e colunas geradas. | +| `Dapper.FluentMap.Analyzers` | Analyzers Roslyn para erros de configuração detectáveis estaticamente. | +| `Dapper.FluentMap.Generators` | Source generator para registro de maps em tempo de build. | + +```powershell +Install-Package Dapper.FluentMap +``` + +ou: + +```bash +dotnet add package Dapper.FluentMap +``` + +O pacote principal tem target `netstandard2.0` e depende do Dapper. + +## Início Rápido + ```csharp +using Dapper; +using Dapper.FluentMap; +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public int Id { get; set; } + + public string Name { get; set; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + } +} + FluentMapper.Initialize(config => +{ + config.AddMap(new CustomerMap()); +}); + +var customer = connection.QuerySingle( + "SELECT 7 AS customer_id, 'Ada' AS Name;"); +``` + +Chame `FluentMapper.Initialize(...)` durante o startup da aplicação e trate a configuração efetiva como somente leitura depois que as consultas começarem. + +## Mapeamento + +Crie um map herdando de `EntityMap`: + +```csharp +public sealed class ProductMap : EntityMap +{ + public ProductMap() { - config.AddMapsFromAssemblyContaining(); - config.AddMapsFromAssembly(typeof(ProductMap).Assembly, "App.Domain.Maps"); - }); + Map(product => product.Id).ToColumn("product_id"); + Map(product => product.Name).ToColumn("product_name", caseSensitive: false); + Map(product => product.LastModified).Ignore(); + } +} ``` -#### Trimming and Native AOT -For applications published with IL trimming, single-file or Native AOT, prefer explicit registration: +Mapeamentos explícitos têm precedência sobre convenções. Membros não mapeados usam o comportamento normal do Dapper. + +Mapeamentos explícitos herdados podem ser incluídos quando a entidade derivada deve reutilizar um map da entidade base: ```csharp -FluentMapper.Initialize(config => +public sealed class PreferredCustomerMap : EntityMap +{ + public PreferredCustomerMap() { - config.AddMap(); - config.AddConvention().ForEntity(); - }); + IncludeBase(); + Map(customer => customer.Tier).ToColumn("tier"); + } +} ``` -Assembly scanning APIs such as `AddMapsFromAssembly(...)`, `AddMapsFromAssemblyContaining()`, `ForEntitiesInAssembly(...)`, `ForEntitiesInCurrentAssembly(...)` and the legacy `ApplyMapsFromAssemblies(...)` depend on reflection discovery and are annotated as trimming-sensitive. They remain supported for normal runtime usage, but they can warn or fail after trimming if discovered types or metadata are removed. +Registre o map da base antes do map derivado. -#### Generated mapping registration -Consumers can opt into `Dapper.FluentMap.Generators` to generate explicit registration for maps declared in the current project: +## Configuração + +Registre maps explicitamente: ```csharp -using Dapper.FluentMap; +FluentMapper.Initialize(config => +{ + config.AddMap(); + config.AddMap(); +}); +``` +Assembly scanning está disponível para cenários normais de runtime: + +```csharp FluentMapper.Initialize(config => - { - config.AddGeneratedMappings(); - }); +{ + config.AddMapsFromAssemblyContaining(); + config.AddMapsFromAssembly(typeof(CustomerMap).Assembly, "App.Domain.Maps"); +}); ``` -The generated method calls `AddMap()` for each eligible map in the current compilation. It does not scan referenced assemblies, instantiate maps during generation, or generate materializers. +Use registro explícito em aplicações com trimming ou Native AOT. -#### Convention based mapping -When you have a lot of entity types, creating manual mapping classes can become plumbing. If your column names adhere to some kind of naming convention, you might be better off by configuring a mapping convention. +Você pode validar a configuração atual depois do registro: -You can create a convention by creating a class which derives from the [`Convention`](https://github.com/henkmollema/Dapper-FluentMap/blob/master/src/Dapper.FluentMap/Conventions/Convention.cs) class. In the contructor you can configure the property conventions: ```csharp -public class TypePrefixConvention : Convention +FluentMapper.Initialize(config => config.AddMap()); +FluentMapper.Validate(); +``` + +Para inspeção somente leitura, use `FluentMapper.GetEntityMaps()` e `FluentMapper.GetTypeConventions()`. Os dicionários públicos mutáveis `FluentMapper.EntityMaps` e `FluentMapper.TypeConventions` permanecem por compatibilidade, mas código novo deve preferir as APIs de registro. + +## Convenções e Políticas de Nomenclatura + +Convenções permitem mapear padrões repetidos de colunas: + +```csharp +using Dapper.FluentMap.Conventions; + +public sealed class PrefixConvention : Convention { - public TypePrefixConvention() + public PrefixConvention() { - // Map all properties of type int and with the name 'id' to column 'autID'. - Properties() - .Where(c => c.Name.ToLower() == "id") - .Configure(c => c.HasColumnName("autID")); - - // Prefix all properties of type string with 'str' when mapping to column names. - Properties() - .Configure(c => c.HasPrefix("str")); - - // Prefix all properties of type int with 'int' when mapping to column names. - Properties() - .Configure(c => c.HasPrefix("int")); + Properties() + .Configure(property => property.HasPrefix("col")); } } + +FluentMapper.Initialize(config => +{ + config.AddConvention() + .ForEntity(); +}); ``` -When initializing Dapper.FluentMap with conventions, the entities on which a convention applies must be configured. You can choose to either configure the entities explicitly or use assembly scanning. +Políticas de nomenclatura cobrem transformações comuns: ```csharp +using Dapper.FluentMap.Naming; + FluentMapper.Initialize(config => +{ + config.UseNamingPolicy(NamingPolicy.SnakeCase, caseSensitive: false) + .ForEntity(); +}); +``` + +As políticas disponíveis incluem `Identity`, `SnakeCase`, `Prefix(...)`, `Suffix(...)`, `Custom(...)` e composição com `Then(...)`, `WithPrefix(...)` e `WithSuffix(...)`. + +## Tipos Imutáveis e Constructor Mapping + +FluentMap participa do constructor mapping do Dapper para mapeamentos explícitos no nível raiz: + +```csharp +public sealed class Customer +{ + public Customer(int id, string fullName) { - // Configure entities explicitly. - config.AddConvention() - .ForEntity() - .ForEntity; + Id = id; + FullName = fullName; + } + + public int Id { get; } - // Configure all entities in a certain assembly with an optional namespaces filter. - config.AddConvention() - .ForEntitiesInAssembly(typeof(Product).Assembly, "App.Domain.Model"); + public string FullName { get; } +} - // Configure all entities in the current assembly with an optional namespaces filter. - config.AddConvention() - .ForEntitiesInCurrentAssembly("App.Domain.Model.Catalog", "App.Domain.Model.Order"); - }); +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.FullName).ToColumn("full_name"); + } +} ``` -##### Transformations -The convention API allows you to configure transformation of property names to database column names. An implementation would look like this: +Quando você precisa que o FluentMap construa objetos aninhados imutáveis ou Value Objects, use `QueryMapped*`. + +## Mapeamento de Objetos Aninhados + +Caminhos aninhados usam a mesma API `Map(...)`: + ```csharp -public class PropertyTransformConvention : Convention +public sealed class CustomerMap : EntityMap { - public PropertyTransformConvention() + public CustomerMap() { - Properties() - .Configure(c => c.Transform(s => Regex.Replace(input: s, pattern: "([A-Z])([A-Z][a-z])|([a-z0-9])([A-Z])", replacement: "$1$3_$2$4"))); + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Address.City).ToColumn("city"); } } ``` -This configuration will map camel case property names to underscore seperated database column names (`UrlOptimizedName` -> `Url_Optimized_Name`). +Use os helpers opt-in do FluentMap para materializar o grafo de objetos: -
+```csharp +var customer = connection.QueryMappedSingle( + "SELECT 7 AS customer_id, 'Sao Paulo' AS city;"); +``` -### [Dommel](https://github.com/henkmollema/Dommel) -Dommel contains a set of extensions methods providing easy CRUD operations using Dapper. One of the goals was to provide extension points for resolving table and column names. [Dapper.FluentMap.Dommel](https://github.com/henkmollema/Dapper-FluentMap/tree/master/src/Dapper.FluentMap.Dommel) implements certain interfaces of Dommel and uses the configured mapping. It also provides more mapping functionality. +`QueryMapped*` cria objetos intermediários suportados, preserva semântica de null em subárvores aninhadas e rejeita caminhos não suportados com `FluentMapConfigurationException`. -#### [`PM> Install-Package Dapper.FluentMap.Dommel`](https://www.nuget.org/packages/Dapper.FluentMap.Dommel) +## Value Objects -#### Usage -##### `DommelEntityMap` -This class derives from `EntityMap` and allows you to map an entity to a database table using the `ToTable()` method: +Para Value Objects escalares mapeados como uma propriedade inteira, prefira um `TypeHandler` do Dapper: ```csharp -public class ProductMap : DommelEntityMap +Map(customer => customer.Cpf).ToColumn("cpf"); +``` + +Para Value Objects mapeados pelos seus componentes, `QueryMapped*` pode construí-los por construtores públicos compatíveis: + +```csharp +public sealed class CustomerMap : EntityMap { - public ProductMap() + public CustomerMap() { - ToTable("tblProduct"); + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Cpf.Number).ToColumn("cpf"); + } +} + +var customer = connection.QueryMappedSingle( + "SELECT 1 AS customer_id, '12345678909' AS cpf;"); +``` + +Factory methods não são usadas pelo materializador de runtime atual. + +## Mapping Profiles - // ... +Profiles são mapeamentos opt-in para a mesma entidade em formatos SQL diferentes: + +```csharp +using Dapper.FluentMap.Mapping; + +public sealed class LegacyProfile : IMappingProfile +{ +} + +public sealed class LegacyCustomerMap : + EntityMap, + IProfileMap +{ + public LegacyCustomerMap() + { + Map(customer => customer.Id).ToColumn("id"); + Map(customer => customer.Name).ToColumn("legal_name"); } } + +FluentMapper.Initialize(config => +{ + config.AddMap(); + config.AddProfile(); +}); + +var legacy = connection.QueryMappedSingle( + "SELECT 7 AS id, 'Legacy Ltd.' AS legal_name;"); ``` -##### `DommelPropertyMap` -This class derives `PropertyMap` and allows you to specify the key property of an entity using the `IsKey` method: +Profiles são selecionados por operação com `QueryMapped()`. Eles não substituem o type map global do Dapper para a entidade. + +## Diagnósticos + +Use validação em runtime para falhar cedo depois da configuração: + +```csharp +FluentMapper.Validate(); +``` + +Use `Explain()` ou `Explain()` para inspecionar o mapeamento efetivo: ```csharp -public class ProductMap : DommelEntityMap +var explanation = FluentMapper.Explain(); + +foreach (var member in explanation.Members) +{ + Console.WriteLine($"{member.MemberPath} -> {member.ColumnName} ({member.Source})"); +} +``` + +## Source Generator e Analyzers + +`Dapper.FluentMap.Analyzers` reporta erros de configuração que podem ser provados em tempo de compilação, como expressões de map inválidas, caminhos de membros duplicados, colunas duplicadas e registros de profile inválidos. Ele complementa a validação de runtime e não executa construtores de maps nem faz scan de assemblies. + +`Dapper.FluentMap.Generators` descobre implementações elegíveis de `IEntityMap` na compilação atual e emite `AddGeneratedMappings()`: + +```csharp +FluentMapper.Initialize(config => +{ + config.AddGeneratedMappings(); +}); +``` + +O registro gerado chama os caminhos existentes `AddMap()` / `AddProfile()`. Ele não gera materializadores de banco, não escaneia assemblies referenciados e não substitui `FluentMapper.Validate()`. + +## Trimming / Native AOT + +FluentMap tem níveis diferentes de suporte conforme a API: + +| Área da API | Status para trimming / Native AOT | +|---|---| +| Registro explícito `AddMap()` | Caminho preferencial para aplicações com trimming e Native AOT. | +| Registro gerado | Alternativa útil ao assembly scanning para maps da compilação atual. | +| APIs de assembly scanning | Baseadas em descoberta por reflection e anotadas como sensíveis a trimming. | +| `QueryMapped*` | Baseado em reflection e código dinâmico em runtime; anotado com warnings de trimming e dynamic code. | + +Não trate o pacote como totalmente seguro para Native AOT apenas porque o registro explícito funciona. Prefira registro explícito ou gerado e evite scanning por reflection em aplicações com trimming. + +## Integração com Dapper + +FluentMap instala type maps do Dapper para entidades configuradas. As APIs normais do Dapper continuam sendo o caminho padrão para mapeamento no nível raiz: + +```csharp +connection.Query(sql); +connection.QuerySingle(sql); +``` + +Use os helpers de consulta do FluentMap quando precisar de materialização avançada controlada pelo FluentMap: + +```csharp +connection.QueryMapped(sql); +connection.QueryMappedSingle(sql); +connection.QueryMappedSingle(sql); +``` + +`QueryMapped*` retorna resultados bufferizados e é o caminho que suporta materialização de objetos aninhados, Value Objects construídos por construtor e mapeamento específico por profile. + +## Dommel + +Instale `Dapper.FluentMap.Dommel` ao usar [Dommel](https://github.com/henkmollema/Dommel): + +```bash +dotnet add package Dapper.FluentMap.Dommel +``` + +Crie maps com `DommelEntityMap` quando precisar de metadados específicos do Dommel para tabela e chave: + +```csharp +using Dapper.FluentMap.Dommel.Mapping; +using Dapper.FluentMap.Dommel; + +public sealed class ProductMap : DommelEntityMap { public ProductMap() { - Map(p => p.Id).IsKey(); + ToTable("products"); + Map(product => product.Id).ToColumn("product_id").IsKey().IsIdentity(); } } ``` -You can configure Dapper.FluentMap.Dommel in the `FluentMapper.Initialize()` method: +Ative a integração com Dommel durante a configuração do FluentMap: ```csharp FluentMapper.Initialize(config => - { - config.AddMap(new ProductMap()); - config.ForDommel(); - }); -``` - -## Resultado da Etapa 1 - -- Capacidades estabilizadas: parsing de expressoes por membro real, composicao mapping explicito/convention/fallback do Dapper, testes de integracao com materializacao real e cache interno estruturado. -- Principais decisoes: `FluentMapper` permanece como fachada publica; `MappingRegistry` e o dono interno de mappings/cache; `SqlMapper.SetTypeMap` continua como integracao global necessaria com o Dapper. -- Dividas transferidas: dicionarios publicos mutaveis preservados por compatibilidade, consumo direto pelo Dommel, paralelismo da suite ainda desabilitado, MemberPath/nested objects/Value Objects fora desta etapa. -- Relatorios: `docs/sdd/etapa-1/01-reflection-helper.md`, `docs/sdd/etapa-1/02-mapping-composition.md`, `docs/sdd/etapa-1/03-dapper-integration-tests.md`, `docs/sdd/etapa-1/04-mapping-registry-cache.md`. - -## Resultado da Etapa 2 - -- Capacidades estabilizadas: `MemberPath` para identidade interna de propriedades, validacao fail-fast com `FluentMapConfigurationException`, heranca opt-in por `IncludeBase()` e naming policies configuraveis via `UseNamingPolicy(...)`. -- Precedencia consolidada: mapping explicito do derivado, mapping explicito herdado mais proximo, mapping explicito herdado mais distante, convention/naming policy do tipo consultado e fallback do Dapper. -- APIs publicas adicionadas: `Dapper.FluentMap.FluentMapConfigurationException`, `EntityMap.IncludeBase()`, `Dapper.FluentMap.Naming.NamingPolicy` e `FluentMapConfiguration.UseNamingPolicy(...)`. -- Naming policies implementadas: `SnakeCase`, `Prefix`, `Suffix`, `Custom` e composicao por `Then`, `WithPrefix` e `WithSuffix`, sem alterar `DefaultTypeMap.MatchNamesWithUnderscores`. -- Dividas adiadas: nested object materialization, Value Objects complexos, constructor/record mapping, multiple mapping profiles, Roslyn analyzers, source generators e AOT/trimming. -- Relatorios: `docs/sdd/etapa-2/01-member-path.md`, `docs/sdd/etapa-2/02-configuration-validation.md`, `docs/sdd/etapa-2/03-inherited-mappings.md`, `docs/sdd/etapa-2/04-naming-policies.md`. - -## Resultado da Etapa 4 - -- Tooling disponivel: `Dapper.FluentMap.Analyzers` com diagnostics `DFM001` a `DFM005` e `Dapper.FluentMap.Generators` com `AddGeneratedMappings()`, `DFM006`, `DFM007` e `DFM008`. -- Trimming: registro explicito e registro gerado foram validados em smoke trimmed sem warnings FluentMap-owned; assembly scanning permanece reflection-dependent e trimming-sensitive. -- Native AOT: publish continua bloqueado neste ambiente por ausencia do platform linker C++; nao ha declaracao de runtime AOT completo. -- Caminhos de registro: manual, gerado e assembly scanning coexistem; nenhum caminho antigo foi removido. -- Packaging: analyzer e generator ficam em `analyzers/dotnet/cs`; o core continua `netstandard2.0` sem dependencias Roslyn runtime. -- Limitacoes: o generator descobre apenas maps da compilacao atual e nao resolve nested object materialization, Value Objects complexos, multiple mapping profiles, query-specific mappings, custom materializer ou generated `DbDataReader` materializer. -- Relatorios: `docs/sdd/etapa-4/01-roslyn-analyzers.md`, `docs/sdd/etapa-4/02-trimming-aot.md`, `docs/sdd/etapa-4/03-source-generator.md`. - -## Resultado da Etapa 5 - -- Nested object materialization e Value Objects imutaveis sao suportados no caminho opt-in `QueryMapped*`, com null semantics por subarvore e construcao por construtores publicos. -- TypeHandlers do Dapper continuam sendo o caminho recomendado para Value Objects escalares mapeados como propriedade inteira. -- Mapping profiles foram adicionados por marker tipado (`IMappingProfile` + `IProfileMap`) e selecionados explicitamente por operacao em `QueryMapped()`. -- O mapping default permanece compativel com `Dapper.Query()`; profiles nao trocam `SqlMapper.SetTypeMap` temporariamente. -- Concorrencia sync e async foi validada para profiles distintos sem vazamento de mapping. -- `Explain()`, analyzer e source generator foram atualizados para distinguir default e profiles. -- `QueryMapped*` permanece reflection-based e anotado para trimming/AOT; o generator atual gera registro, nao materializer de `DbDataReader`. -- Limitacoes principais: sem per-profile conventions, sem multi-mapping com profile, sem streaming unbuffered e sem factory methods para Value Objects. -- Relatorios: `docs/sdd/etapa-5/01-nested-materialization-spike.md`, `docs/sdd/etapa-5/02-nested-object-materialization.md`, `docs/sdd/etapa-5/03-value-objects.md`, `docs/sdd/etapa-5/04-mapping-profiles.md`. - -## Resultado da Etapa 6 - -- Configuration lifecycle formalizado como startup/configuration seguido de operational phase read-only. -- Mapping state ganhou snapshots read-only, preservando campos publicos mutaveis por compatibilidade. -- Compatibilidade Dapper-specific foi isolada em adapters internos; `IgnoredPropertyInfo` foi removido. -- Spike de generated `DbDataReader` materializer concluiu `GO WITH CONSTRAINTS`: geracao e tecnicamente viavel para mappings estaticos, mas deve coexistir com runtime fallback. -- `FM-RISK-004` nao foi resolvido pelo spike; ele recebeu evidencia e arquitetura recomendada para uma etapa futura. -- Relatorios: `docs/sdd/etapa-6/01-configuration-lifecycle.md`, `docs/sdd/etapa-6/02-mapping-state-encapsulation.md`, `docs/sdd/etapa-6/03-dapper-compatibility-adapters.md`, `docs/sdd/etapa-6/04-generated-materializer-spike.md`. +{ + config.AddMap(new ProductMap()); + config.ForDommel(); +}); +``` + +## Limitações Atuais + +- A configuração do FluentMap é global no processo. Configure no startup e evite alterar mappings enquanto consultas estão em execução. +- Assembly scanning depende de descoberta por reflection e não é o caminho recomendado para aplicações com trimming ou Native AOT. +- `QueryMapped*` usa metadados de runtime e código dinâmico; ele não é o caminho de materialização seguro para Native AOT. +- Mapping profiles são selecionados apenas pelas APIs `QueryMapped()`. +- `QueryMapped*` é bufferizado; ele não expõe streaming unbuffered. +- A construção de Value Objects usa construtores públicos compatíveis, não factory methods. + +## Contribuição + +Mantenha mudanças pequenas, compatíveis com a API pública e cobertas por testes focados. A biblioteca principal deve continuar sendo uma camada de FluentMap para Dapper, não um ORM, gerador de SQL ou abstração de CRUD. + +Validação típica: + +```bash +dotnet restore ./Dapper.FluentMap.sln +dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore +dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build +``` + +## Licença + +FluentMap é licenciado sob a [MIT License](LICENSE). diff --git a/src/Dapper.FluentMap.Analyzers/README.md b/src/Dapper.FluentMap.Analyzers/README.md index a5f5e19..0acb6fa 100644 --- a/src/Dapper.FluentMap.Analyzers/README.md +++ b/src/Dapper.FluentMap.Analyzers/README.md @@ -2,4 +2,10 @@ Roslyn analyzers for statically provable `Dapper.FluentMap` configuration errors. +Install it alongside the core package when you want compile-time feedback for invalid map expressions, duplicate member paths, duplicate columns, invalid `IncludeBase()` usage and invalid generic map/profile registration. + +```bash +dotnet add package Dapper.FluentMap.Analyzers +``` + The analyzer package complements runtime validation. It does not execute user mapping constructors, scan assemblies, access databases or replace `FluentMapper.Validate()`. diff --git a/src/Dapper.FluentMap.Generators/README.md b/src/Dapper.FluentMap.Generators/README.md index 039bced..e241cbe 100644 --- a/src/Dapper.FluentMap.Generators/README.md +++ b/src/Dapper.FluentMap.Generators/README.md @@ -3,3 +3,18 @@ Build-time source generator for Dapper.FluentMap mapping registration. The generator discovers eligible `IEntityMap` implementations declared in the current compilation and emits an `AddGeneratedMappings()` extension method that registers them through the existing `AddMap()` API. + +```bash +dotnet add package Dapper.FluentMap.Generators +``` + +```csharp +using Dapper.FluentMap; + +FluentMapper.Initialize(config => +{ + config.AddGeneratedMappings(); +}); +``` + +Generated registration avoids reflection-based assembly scanning for maps in the current compilation. It does not scan referenced assemblies, execute map constructors during generation, generate database materializers or replace `FluentMapper.Validate()`. From 33b44a84f4172d4bfba9968976068bbb41d0ceca Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Sun, 26 Jul 2026 20:20:59 -0300 Subject: [PATCH 20/20] chore: stop tracking local documentation folders --- .agents/skills/assertion-quality/SKILL.md | 176 --- .agents/skills/coverage-analysis/SKILL.md | 533 --------- .../references/guidelines.md | 59 - .../references/output-format.md | 87 -- .../scripts/Compute-CrapScores.ps1 | 165 --- .../scripts/Extract-MethodCoverage.ps1 | 193 ---- .../detect-static-dependencies/SKILL.md | 149 --- .agents/skills/dotnet-aot-compat/SKILL.md | 269 ----- .../dotnet-aot-compat/references/polyfills.md | 43 - .../migrate-nullable-references/SKILL.md | 291 ----- .../references/aspnet-core.md | 17 - .../references/breaking-changes.md | 8 - .../references/ef-core.md | 18 - .../references/nullable-attributes.md | 19 - .../scripts/Get-NullableReadiness.ps1 | 487 -------- .agents/skills/msbuild-antipatterns/SKILL.md | 409 ------- .../references/additional-antipatterns.md | 315 ----- .../incremental-build-inputs-outputs.md | 30 - .../references/private-assets.md | 22 - .agents/skills/msbuild-modernization/SKILL.md | 501 -------- .agents/skills/run-tests/SKILL.md | 288 ----- .agents/skills/test-anti-patterns/SKILL.md | 173 --- .agents/skills/test-gap-analysis/SKILL.md | 220 ---- .gitignore | 3 + docs/sdd/etapa-1/01-reflection-helper.md | 123 -- docs/sdd/etapa-1/02-mapping-composition.md | 198 ---- .../etapa-1/03-dapper-integration-tests.md | 202 ---- docs/sdd/etapa-1/04-mapping-registry-cache.md | 225 ---- docs/sdd/etapa-1/README.md | 26 - docs/sdd/etapa-1/decisions.md | 36 - docs/sdd/etapa-1/status.md | 8 - docs/sdd/etapa-2/01-member-path.md | 202 ---- .../etapa-2/02-configuration-validation.md | 216 ---- docs/sdd/etapa-2/03-inherited-mappings.md | 255 ---- docs/sdd/etapa-2/04-naming-policies.md | 262 ----- docs/sdd/etapa-2/README.md | 43 - docs/sdd/etapa-2/decisions.md | 46 - docs/sdd/etapa-2/status.md | 8 - docs/sdd/etapa-3/01-mapping-registration.md | 290 ----- .../02-constructor-immutable-mapping.md | 207 ---- docs/sdd/etapa-3/03-diagnostics-api.md | 324 ------ docs/sdd/etapa-3/README.md | 77 -- docs/sdd/etapa-3/decisions.md | 36 - docs/sdd/etapa-3/status.md | 7 - docs/sdd/etapa-4/01-roslyn-analyzers.md | 240 ---- docs/sdd/etapa-4/02-trimming-aot.md | 326 ------ docs/sdd/etapa-4/03-source-generator.md | 396 ------- docs/sdd/etapa-4/README.md | 79 -- docs/sdd/etapa-4/decisions.md | 39 - docs/sdd/etapa-4/status.md | 5 - .../01-nested-materialization-spike.md | 494 -------- .../02-nested-object-materialization.md | 271 ----- docs/sdd/etapa-5/03-value-objects.md | 328 ------ docs/sdd/etapa-5/04-mapping-profiles.md | 390 ------- docs/sdd/etapa-5/README.md | 135 --- docs/sdd/etapa-5/decisions.md | 56 - docs/sdd/etapa-5/status.md | 6 - .../sdd/etapa-6/01-configuration-lifecycle.md | 283 ----- .../etapa-6/02-mapping-state-encapsulation.md | 265 ----- .../03-dapper-compatibility-adapters.md | 277 ----- .../04-generated-materializer-spike.md | 570 --------- docs/sdd/etapa-6/README.md | 47 - docs/sdd/etapa-6/decisions.md | 196 ---- docs/sdd/etapa-6/handoff.md | 150 --- docs/sdd/fluentmap-risk-assessment.md | 1027 ----------------- .../net10-migration/01-inventory-baseline.md | 288 ----- .../net10-migration/02-test-projects-net10.md | 182 --- .../net10-migration/03-src-dependencies.md | 206 ---- .../net10-migration/04-validation-pack-ci.md | 329 ------ .../net10-migration/05-xunit3-migration.md | 363 ------ docs/sdd/net10-migration/README.md | 132 --- docs/sdd/net10-migration/decisions.md | 99 -- docs/sdd/net10-migration/dependency-matrix.md | 128 -- docs/sdd/net10-migration/status.md | 9 - .../sqlitepclraw-vulnerability.md | 156 --- 75 files changed, 3 insertions(+), 14735 deletions(-) delete mode 100644 .agents/skills/assertion-quality/SKILL.md delete mode 100644 .agents/skills/coverage-analysis/SKILL.md delete mode 100644 .agents/skills/coverage-analysis/references/guidelines.md delete mode 100644 .agents/skills/coverage-analysis/references/output-format.md delete mode 100644 .agents/skills/coverage-analysis/scripts/Compute-CrapScores.ps1 delete mode 100644 .agents/skills/coverage-analysis/scripts/Extract-MethodCoverage.ps1 delete mode 100644 .agents/skills/detect-static-dependencies/SKILL.md delete mode 100644 .agents/skills/dotnet-aot-compat/SKILL.md delete mode 100644 .agents/skills/dotnet-aot-compat/references/polyfills.md delete mode 100644 .agents/skills/migrate-nullable-references/SKILL.md delete mode 100644 .agents/skills/migrate-nullable-references/references/aspnet-core.md delete mode 100644 .agents/skills/migrate-nullable-references/references/breaking-changes.md delete mode 100644 .agents/skills/migrate-nullable-references/references/ef-core.md delete mode 100644 .agents/skills/migrate-nullable-references/references/nullable-attributes.md delete mode 100644 .agents/skills/migrate-nullable-references/scripts/Get-NullableReadiness.ps1 delete mode 100644 .agents/skills/msbuild-antipatterns/SKILL.md delete mode 100644 .agents/skills/msbuild-antipatterns/references/additional-antipatterns.md delete mode 100644 .agents/skills/msbuild-antipatterns/references/incremental-build-inputs-outputs.md delete mode 100644 .agents/skills/msbuild-antipatterns/references/private-assets.md delete mode 100644 .agents/skills/msbuild-modernization/SKILL.md delete mode 100644 .agents/skills/run-tests/SKILL.md delete mode 100644 .agents/skills/test-anti-patterns/SKILL.md delete mode 100644 .agents/skills/test-gap-analysis/SKILL.md delete mode 100644 docs/sdd/etapa-1/01-reflection-helper.md delete mode 100644 docs/sdd/etapa-1/02-mapping-composition.md delete mode 100644 docs/sdd/etapa-1/03-dapper-integration-tests.md delete mode 100644 docs/sdd/etapa-1/04-mapping-registry-cache.md delete mode 100644 docs/sdd/etapa-1/README.md delete mode 100644 docs/sdd/etapa-1/decisions.md delete mode 100644 docs/sdd/etapa-1/status.md delete mode 100644 docs/sdd/etapa-2/01-member-path.md delete mode 100644 docs/sdd/etapa-2/02-configuration-validation.md delete mode 100644 docs/sdd/etapa-2/03-inherited-mappings.md delete mode 100644 docs/sdd/etapa-2/04-naming-policies.md delete mode 100644 docs/sdd/etapa-2/README.md delete mode 100644 docs/sdd/etapa-2/decisions.md delete mode 100644 docs/sdd/etapa-2/status.md delete mode 100644 docs/sdd/etapa-3/01-mapping-registration.md delete mode 100644 docs/sdd/etapa-3/02-constructor-immutable-mapping.md delete mode 100644 docs/sdd/etapa-3/03-diagnostics-api.md delete mode 100644 docs/sdd/etapa-3/README.md delete mode 100644 docs/sdd/etapa-3/decisions.md delete mode 100644 docs/sdd/etapa-3/status.md delete mode 100644 docs/sdd/etapa-4/01-roslyn-analyzers.md delete mode 100644 docs/sdd/etapa-4/02-trimming-aot.md delete mode 100644 docs/sdd/etapa-4/03-source-generator.md delete mode 100644 docs/sdd/etapa-4/README.md delete mode 100644 docs/sdd/etapa-4/decisions.md delete mode 100644 docs/sdd/etapa-4/status.md delete mode 100644 docs/sdd/etapa-5/01-nested-materialization-spike.md delete mode 100644 docs/sdd/etapa-5/02-nested-object-materialization.md delete mode 100644 docs/sdd/etapa-5/03-value-objects.md delete mode 100644 docs/sdd/etapa-5/04-mapping-profiles.md delete mode 100644 docs/sdd/etapa-5/README.md delete mode 100644 docs/sdd/etapa-5/decisions.md delete mode 100644 docs/sdd/etapa-5/status.md delete mode 100644 docs/sdd/etapa-6/01-configuration-lifecycle.md delete mode 100644 docs/sdd/etapa-6/02-mapping-state-encapsulation.md delete mode 100644 docs/sdd/etapa-6/03-dapper-compatibility-adapters.md delete mode 100644 docs/sdd/etapa-6/04-generated-materializer-spike.md delete mode 100644 docs/sdd/etapa-6/README.md delete mode 100644 docs/sdd/etapa-6/decisions.md delete mode 100644 docs/sdd/etapa-6/handoff.md delete mode 100644 docs/sdd/fluentmap-risk-assessment.md delete mode 100644 docs/sdd/net10-migration/01-inventory-baseline.md delete mode 100644 docs/sdd/net10-migration/02-test-projects-net10.md delete mode 100644 docs/sdd/net10-migration/03-src-dependencies.md delete mode 100644 docs/sdd/net10-migration/04-validation-pack-ci.md delete mode 100644 docs/sdd/net10-migration/05-xunit3-migration.md delete mode 100644 docs/sdd/net10-migration/README.md delete mode 100644 docs/sdd/net10-migration/decisions.md delete mode 100644 docs/sdd/net10-migration/dependency-matrix.md delete mode 100644 docs/sdd/net10-migration/status.md delete mode 100644 docs/sdd/security-hardening/sqlitepclraw-vulnerability.md diff --git a/.agents/skills/assertion-quality/SKILL.md b/.agents/skills/assertion-quality/SKILL.md deleted file mode 100644 index fa8b630..0000000 --- a/.agents/skills/assertion-quality/SKILL.md +++ /dev/null @@ -1,176 +0,0 @@ ---- -name: assertion-quality -description: "Analyzes the variety and depth of assertions across test suites in any language. Use when the user asks to evaluate assertion quality, find shallow tests, identify assertion-free tests (no assertions or only trivial ones like Assert.IsNotNull / toBeTruthy()), flag self-referential or tautological assertions, measure assertion diversity, or audit whether tests verify different facets of behavior. Polyglot: .NET, Python, TS/JS, Java, Go, Ruby, Rust, Swift, Kotlin, PowerShell, C++. DO NOT USE FOR: writing new tests (use code-testing-agent / writing-mstest-tests), mutation reasoning about whether tests would catch a bug (use test-gap-analysis), or a general severity-ranked anti-pattern audit (use test-anti-patterns), fixing or rewriting assertions, or writing, fixing, or modernizing MSTest tests, assertions, or attributes (use writing-mstest-tests)." -license: MIT ---- - -# Assertion Diversity Analysis - -Analyze test code in any supported language to measure how varied and meaningful the assertions are. Produce a metrics report that reveals whether tests verify different facets of correctness — not just "output equals X" but also structure, exceptions, state transitions, side effects, and invariants. - -> **Language-specific guidance**: Call the `test-analysis-extensions` skill to discover available extension files, then read the file matching the target codebase's language and framework (e.g., `dotnet.md` for .NET, `python.md` for pytest, `typescript.md` for Jest, `go.md` for the standard `testing` package). You MUST read the relevant extension file before classifying assertions, because assertion APIs differ significantly across frameworks. - -## Why Assertion Diversity Matters - -Low assertion diversity signals shallow testing. Tests may pass while bugs hide in unasserted logic. Common symptoms: - -| Problem | Symptom | Consequence | -|---------|---------|-------------| -| Trivial assertions | Test contains only `Assert.IsNotNull(result)` / `assert result is not None` / `expect(x).toBeDefined()` | Test passes but doesn't verify correctness | -| Single-value obsession | Always check one field or return value | Bugs in unasserted logic slip through | -| No negative assertions | Never check what shouldn't happen | Regressions sneak in through false positives | -| No state checks | Don't verify object state changes | Missed side-effects or lifecycle issues | -| No structural checks | Only assert top-level value | Bugs in nested objects go unnoticed | -| Assertion-free tests | Tests that call but don't verify | Code coverage lies; false security | - -## When to Use - -- User asks to evaluate assertion quality or depth -- User asks "are my tests actually testing anything meaningful?" -- User wants to know if test assertions are too shallow or trivial -- User asks for assertion coverage metrics or diversity analysis -- User suspects tests give false confidence despite passing -- The `code-testing-generator` agent (or any test-generation workflow) calls this skill as a pre-completion self-review step on freshly generated tests, before declaring the run finished - -## When Not to Use - -- User wants to write new tests (use `code-testing-agent` for any language, or `writing-mstest-tests` for MSTest specifically) -- User wants to detect anti-patterns beyond assertions (use `test-anti-patterns`) -- User wants to fix or rewrite assertions (help them directly) -- User asks about code coverage percentages (out of scope — this analyzes assertion quality, not line coverage) - -## Inputs - -| Input | Required | Description | -|-------|----------|-------------| -| Test code | Yes | One or more test files or a test project directory to analyze | -| Production code | No | The code under test, to evaluate whether assertions cover the important behaviors | - -## Workflow - -### Step 1: Detect language and load extension - -Identify the target codebase's language and test framework. Call the `test-analysis-extensions` skill and read the matching extension file (e.g., `extensions/dotnet.md` for .NET, `extensions/python.md` for pytest, `extensions/typescript.md` for Jest/Vitest, `extensions/go.md` for Go). The extension file lists the framework-specific assertion APIs you will classify in Step 3. - -### Step 2: Gather the test code - -Read all test files the user provides. If the user points to a directory or project, scan for all test files using the markers in the language extension file (e.g., `[TestMethod]` for MSTest, `def test_*` for pytest, `it()` / `test()` for Jest, `func TestXxx` for Go). - -### Step 3: Classify every assertion - -For each test method, identify all assertions and classify them into these language-neutral categories: - -| Category | What it verifies | Examples across languages | -|----------|------------------|----------------------------| -| **Equality** | Return value matches expected | `Assert.AreEqual` (MSTest), `Assert.Equal` (xUnit), `assert x == y` (pytest), `expect(x).toBe(y)` (Jest), `assertEquals` (JUnit), `if got != want { t.Error... }` / `assert.Equal(t, want, got)` (Go), `x shouldBe y` (Kotest), `Should -Be` (Pester), `EXPECT_EQ` (GoogleTest) | -| **Boolean** | Condition holds | `Assert.IsTrue`, `assert flag` (Python), `expect(x).toBeTruthy()` (Jest), `assertTrue` (JUnit), `assert.True(t, ok)` (testify), `x.shouldBeTrue()` (Kotest), `Should -BeTrue` (Pester), `EXPECT_TRUE` | -| **Null / None / Nil** | Presence/absence of value | `Assert.IsNull` (.NET), `assert x is None` (pytest), `expect(x).toBeNull()` (Jest), `assertNull` (JUnit), `assert.Nil(t, v)` (testify), `XCTAssertNil` (XCTest), `Should -BeNullOrEmpty` (Pester) | -| **Exception / Error** | Error handling behavior | `Assert.Throws()`, `pytest.raises(E)`, `expect(fn).toThrow(E)`, `assertThrows`, `assert.Error(t, err)` / `assert.ErrorIs`, `#[should_panic]` (Rust), `XCTAssertThrowsError`, `Should -Throw`, `EXPECT_THROW` | -| **Type checks** | Runtime type correctness | `Assert.IsInstanceOfType`, `assert isinstance(x, T)`, `expect(x).toBeInstanceOf(T)`, `assertInstanceOf`, `assert.IsType(t, T{}, v)`, `assert!(matches!(value, Pattern))` (Rust), `Should -BeOfType` | -| **String** | Text content and format | `StringAssert.Contains`, `assert sub in s`, `expect(s).toMatch(/x/)`, `assertTrue(s.contains(...))`, `assert.Contains(t, s, sub)`, `s shouldContain sub`, `Should -Match`, `EXPECT_THAT(s, HasSubstr(...))` | -| **Collection** | Collection contents and structure | `CollectionAssert.Contains`, `assert item in collection`, `expect(arr).toContain(x)`, `assertIterableEquals`, `assert.Contains(t, slice, item)`, `col shouldContainExactly listOf(...)`, `Should -Contain`, `EXPECT_THAT(c, ElementsAre(...))` | -| **Comparison** | Ordering and magnitude | `Assert.IsTrue(x > y)`, `Is.GreaterThan`, `assert x > y`, `expect(x).toBeGreaterThan(y)`, `assertTrue(x > y)`, `assert.Greater(t, x, y)` (testify) | -| **Approximate** | Floating-point or tolerance-based | `Assert.AreEqual(expected, actual, delta)`, `pytest.approx(y)`, `expect(x).toBeCloseTo(y)`, `assertEquals(x, y, delta)`, `assert.InDelta(t, x, y, delta)`, `EXPECT_NEAR`, `EXPECT_DOUBLE_EQ` | -| **Negative** | What should NOT happen | `Assert.AreNotEqual`, `assert x != y`, `expect(x).not.toBe(y)`, `assertNotEquals`, `assert.NotEqual(t, x, y)`, `refute` (Minitest / Ruby), `Should -Not -Be` | -| **State / Side-effect** | State transitions and side effects | Assertions on object properties after mutation; mock-call verifications: `mock.Verify(...)` (Moq), `mock_method.assert_called_with(...)` (Python `unittest.mock`), `expect(mock).toHaveBeenCalledWith(...)` (Jest), `verify(mock).method(...)` (Mockito), `Should -Invoke` (Pester), `expect { code }.to change(obj, :attr)` (RSpec) | -| **Structural / Deep** | Deep object correctness | `Assert.AreEqual` with rich-equality types, `assertThat(obj).usingRecursiveComparison()` (AssertJ), `.toEqual({...})` (Jest deep equality), `cmp.Diff` (Go go-cmp), snapshot tests (`.toMatchSnapshot()`, `syrupy`, `SnapshotTesting`), `assertThat(col).extracting(...)` (AssertJ chains) | - -A single assertion can belong to multiple categories (e.g., `Assert.AreNotEqual` is both Equality and Negative; `expect(mock).toHaveBeenCalledWith(...)` is both State/Side-effect and a specific-call assertion). - -Read the loaded language extension file for the exact framework-specific list of assertion APIs. - -### Step 4: Compute metrics - -Calculate these metrics for the test suite: - -#### Per-test metrics -- **Assertion count**: Number of assertions in each test method -- **Assertion categories**: Which categories each test uses - -#### Suite-wide metrics -- **Average assertions per test**: Total assertions / total test methods -- **Assertion type spread**: Number of distinct assertion categories used across the suite (out of 12) -- **Tests with zero assertions**: Count and percentage of test methods with no assertions at all -- **Tests with only trivial assertions**: Count and percentage of tests where every assertion is only a null check or `Assert.IsTrue(true)` — trivial means no meaningful value verification -- **Tests with self-referential assertions**: Count and percentage of tests whose assertions compare an input to a round-tripped or identity-transformed version of itself (e.g., `Assert.AreEqual(input, Parse(input.ToString()))`) or assert a field against itself (`Assert.AreEqual(dto.Name, dto.Name)`). These are tautological — they verify the plumbing, not the behavior. -- **Tests with negative assertions**: Count and percentage (target: at least 10% of tests should verify what should NOT happen) -- **Tests with exception assertions**: Count and percentage -- **Tests with state/side-effect assertions**: Count and percentage -- **Tests with structural/deep assertions**: Count and percentage -- **Single-category tests**: Count and percentage of tests that use only one assertion category - -### Step 5: Apply calibration rules - -Before reporting, calibrate findings: - -- **Trivial means truly trivial.** A null/None/nil check alone is trivial (`Assert.IsNotNull(result)`, `assert result is not None`, `expect(x).toBeDefined()`). But a null check followed by a meaningful value assertion is not trivial — the null check is a guard before the real assertion. Only flag a test as "trivial" if it has no meaningful value assertions. -- **Boolean assertions checking meaningful conditions are not trivial.** `Assert.IsTrue(result.IsValid)` / `assert result.is_valid` / `expect(result.isValid).toBe(true)` check a specific property — these are Boolean assertions, not trivial ones. Always-true assertions (`Assert.IsTrue(true)`, `assert True`, `expect(true).toBe(true)`) are trivial. -- **Consider the test's intent.** A test for a void method that verifies state change on a dependency is legitimate even if it only uses one Boolean assertion. -- **Exception tests are inherently low-assertion-count.** `Assert.ThrowsException(() => ...)` / `with pytest.raises(E): ...` / `expect(fn).toThrow(E)` / `#[should_panic]` may be the only assertion — that's fine for exception-focused tests. Don't penalize them for low assertion count. -- **Mock-call verifications and bare assertion forms count.** Treat `verify(mock).method(...)` (Mockito), `expect(mock).toHaveBeenCalledWith(...)` (Jest), `Should -Invoke` (Pester), `bare assert` (pytest), `if got != want { t.Errorf(...) }` (Go) all as real assertions of the appropriate category. Do not treat them as missing-framework-API smells. -- **Snapshot assertions** (`.toMatchSnapshot()`, `syrupy`, `SnapshotTesting`) count as Structural/Deep assertions. Flag stale or never-updated snapshots separately. -- **Property-based tests** (`@given` Hypothesis, `proptest!`, `forAll` Kotest) generate assertions implicitly through generated cases — count the inner assertion logic, not the outer scaffold. -- **Don't conflate diversity with volume.** A test with 20 equality assertions has high volume but low diversity. A test with one equality, one null check, and one exception assertion has low volume but good diversity. -- **Self-referential assertions are not meaningful equality checks.** Asserting that an output equals an input round-trip looks like a real equality assertion but is tautological when the operation under test is expected to be identity. Flag these separately from normal equality assertions. If the test's *purpose* is to verify a round-trip (serialize/deserialize, encode/decode), the assertion is valid — but it should be accompanied by assertions on non-trivial inputs that exercise the transformation. -- **If assertions are well-diversified, say so.** A report concluding the suite has good diversity is perfectly valid. - -### Step 6: Report findings - -**Scale the report depth to the size and complexity of the suite.** The structure below is the full template for a substantial suite (roughly 15+ tests or a multi-file project). For a small or simple input (a single file with only a handful of tests), do not emit every section — a padded multi-section dashboard on a trivial input reads as noise and buries the answer. Instead, answer the user's question directly and concisely: which tests are assertion-free or trivial-only, the overall assertion-quality verdict, and concrete recommendations (still distinguishing intentional smoke tests from tests masquerading as real verification). Use only the sections that carry real signal for the input at hand; a short metric summary plus the assertion-free list and recommendations is often enough. Never omit the rubric-relevant substance (assertion-free/trivial identification, the quality verdict, and concrete recommendations) — only trim structural overhead that adds no information. - -Present the analysis in this structure: - -1. **Summary Dashboard** — A quick-reference table of key metrics: - ``` - | Metric | Value | Assessment | - |-------------------------------|--------|------------| - | Total tests | 25 | — | - | Average assertions per test | 2.4 | Moderate | - | Assertion type spread | 5/12 | Low | - | Tests with zero assertions | 3 (12%)| Concerning | - | Tests with only trivial asserts | 4 (16%)| Acceptable | - | Tests with negative assertions | 2 (8%) | Below target | - | Single-category tests | 15 (60%)| High | - ``` - -2. **Category Breakdown** — For each assertion category, show: - - How many tests use it - - Representative examples from the code - - Whether it's overused or underused relative to the code under test - -3. **Gap Analysis** — Based on the production code (if available), identify: - - Behaviors that are tested but only with equality checks - - Error paths with no exception assertions - - State-changing methods with no state verification - - Collections returned but never checked for contents - -4. **Recommendations** — Prioritized list of improvements: - - Which tests would benefit most from additional assertion types - - Which assertion categories are missing and why they matter - - Concrete examples of assertions that could be added - -5. **Assertion-free tests** — If any exist, list each one with its method name and what it appears to be testing, so the user can decide whether to add assertions or mark them as intentional smoke tests. - -## Validation - -- [ ] Every assertion in the test suite was classified into at least one category -- [ ] Metrics are computed correctly (counts add up) -- [ ] Trivial-assertion tests are correctly identified (not over-flagged) -- [ ] Exception tests are not penalized for low assertion count -- [ ] Boolean assertions on meaningful properties are not classified as trivial -- [ ] Recommendations are concrete (name specific test methods and suggest specific assertion types) -- [ ] If the suite has good diversity, the report acknowledges this - -## Common Pitfalls - -| Pitfall | Solution | -|---------|----------| -| Penalizing exception tests for low assertion count | Exception assertions are complete on their own — skip count warnings for these | -| Flagging null/None/nil checks before value checks as trivial | Only flag tests where the null/None/nil check is the ONLY assertion | -| Counting any Boolean assertion as trivial | Only always-true assertions (`Assert.IsTrue(true)`, `assert True`, `expect(true).toBe(true)`) are trivial | -| Ignoring framework differences | Each framework has distinct assertion APIs — always read the matching language extension first. MSTest's `Assert.AreEqual`, xUnit's `Assert.Equal`, NUnit's `Is.EqualTo`, pytest's bare `assert ==`, Jest's `expect().toBe()`, Go's `if … { t.Error… }` all map to the **Equality** category | -| Treating bare assertion forms as missing-framework | Bare `assert` (pytest), `if got != want { t.Error... }` (Go), and `assert!()` (Rust) are canonical — count them in the right category | -| Treating mock-call verifications as assertion-free | `verify(mock).method(...)`, `expect(mock).toHaveBeenCalledWith(...)`, `Should -Invoke` are State/Side-effect assertions | -| Recommending diversity for diversity's sake | Only suggest adding assertion types that would catch real bugs in the code under test | -| Missing implicit assertions | Exception assertions are both Exception and Negative; snapshot/property-based tests are real assertions with implicit structure | -| Async tests with unawaited assertions | TUnit, Jest with `.resolves`/`.rejects`, pytest-asyncio, Swift Testing, and Kotest all silently pass tests where assertions are not `await`ed — treat as assertion-free even when assertion calls are present | diff --git a/.agents/skills/coverage-analysis/SKILL.md b/.agents/skills/coverage-analysis/SKILL.md deleted file mode 100644 index a6de1e0..0000000 --- a/.agents/skills/coverage-analysis/SKILL.md +++ /dev/null @@ -1,533 +0,0 @@ ---- -name: coverage-analysis -description: > - Project-wide code coverage and CRAP (Change Risk Anti-Patterns) score - analysis for .NET projects. Calculates CRAP scores per method and surfaces - risk hotspots — complex code with low coverage that is dangerous to modify. - Use to diagnose why coverage is stuck or plateaued, identify what methods - block improvement, or get project-wide coverage analysis with risk ranking. - USE FOR: coverage stuck, coverage plateau, can't increase coverage, what's - blocking coverage, coverage gap, CRAP scores, risk hotspots, where to add - tests, coverage analysis, coverage report. - DO NOT USE FOR: targeted single-method CRAP analysis (use crap-score); - auditing test code for coverage-touching or other anti-patterns (use - test-anti-patterns); writing tests; running tests (use run-tests). Requires - or produces coverage (Cobertura) and CRAP metrics. -license: MIT ---- - -# Coverage Analysis - -## Purpose - -Raw coverage percentages answer "what code was executed?" — they don't answer what you actually need to know: - -- **What tests should I write next?** — ranked by risk and impact -- **Which uncovered code is risky vs. trivial?** — CRAP scores separate the two -- **Why has coverage plateaued?** — identify the files blocking further gains -- **Is this code safe to refactor?** — complex + uncovered = dangerous to change - -This skill bridges that gap: from a bare .NET solution to a prioritized risk hotspot list, with no manual tool configuration required. - -## When to Use - -Use this skill when the user mentions test coverage, coverage gaps, code risk, CRAP scores, where to add tests, why coverage plateaued, or wants to know which code is safest to refactor — even if they don't explicitly say "coverage analysis". - -## When Not to Use - -- **Targeted single-method CRAP analysis** — use the `crap-score` skill instead -- **Writing or generating tests** — this skill identifies where tests are needed, not write them -- **General test execution** unrelated to coverage or CRAP analysis -- **Coverage reporting without CRAP context** — use `dotnet test` with coverage collection directly - -## Inputs - -| Input | Required | Default | Description | -|-------|----------|---------|-------------| -| Project/solution path | No | Current directory | Path to the .NET solution or project | -| Line coverage threshold | No | 80% | Minimum acceptable line coverage | -| Branch coverage threshold | No | 70% | Minimum acceptable branch coverage | -| CRAP threshold | No | 30 | Maximum acceptable CRAP score before flagging | -| Top N hotspots | No | 10 | Number of risk hotspots to surface | - -### Prerequisites - -- .NET SDK installed (`dotnet` on PATH) -- At least one test project referencing the production code (xUnit, NUnit, or MSTest) — only required for the from-scratch path; not needed when the user supplies an existing Cobertura XML -- **Optional, only for the from-scratch path:** internet/NuGet access for `dotnet add package coverlet.collector` (or `Microsoft.Testing.Extensions.CodeCoverage`) when a test project has no coverage provider yet. Skip when the user supplies an existing Cobertura XML. -- **Optional, only for Phase 5:** internet access for `dotnet tool install` (ReportGenerator). Core CRAP/coverage analysis works from Cobertura XML alone — ReportGenerator only adds HTML/CSV reports as an optional post-summary extra. - -The skill auto-detects coverage provider state per test project and selects the least-invasive execution strategy: - -- unified Microsoft CodeCoverage when all projects use it, -- unified Coverlet when no project uses Microsoft CodeCoverage, -- per-project provider execution when the solution is truly mixed. - -No pre-existing runsettings files or manually installed tools required. - -## Workflow - -> **MANDATORY: deliver the final assistant response with the CRAP/risk-hotspot summary BEFORE any optional work.** As soon as `Compute-CrapScores.ps1` and `Extract-MethodCoverage.ps1` return data, your **next** assistant response must contain the user-facing analysis (CRAP table, blocking methods, recommendations). Do not run ReportGenerator (Phase 5), do not install global tools, and do not start any heavy parallel work before that response is delivered. The user is judged on the final assistant message, not on side-effect files. -> -> If a phase fails, times out, or budget is running low, skip remaining optional work and immediately return a partial summary containing: (1) what was found in the Cobertura XML, (2) any CRAP/risk-hotspot data already extracted, (3) which methods are blocking coverage, and (4) failures encountered. - -If the user provides a path to existing Cobertura XML (or coverage data is already present in `TestResults/`), **skip Phase 2 entirely** (no test execution) **and skip Phase 5 by default** (no ReportGenerator install or HTML report) — go directly from Phase 3 (analysis scripts) to Phase 4 (user-facing summary). Only run Phase 5 if the user explicitly asks for HTML/CSV reports. The Risk Hotspots table and CRAP scores are mandatory in every output — they are the skill's core value-add over raw coverage numbers. - -The workflow runs in five phases. Phases 1–4 are required; Phase 5 (ReportGenerator HTML/CSV reports) is strictly optional and runs **after** the user-facing summary has been delivered. Do not parallelize Phase 5 with earlier phases — the heavy `dotnet tool install` for ReportGenerator can crash the session before Phase 4 completes. - -### Phase 1 — Setup (sequential) - -#### Step 1: Locate the solution or project - -Given the user's path (default: current directory), find the entry point: - -```powershell -$root = "" - -# Prefer solution file; fall back to project file -$sln = Get-ChildItem -Path $root -Filter "*.sln" -Recurse -Depth 2 -ErrorAction SilentlyContinue | - Select-Object -First 1 -if ($sln) { - Write-Host "ENTRY_TYPE:Solution"; Write-Host "ENTRY:$($sln.FullName)" -} else { - $project = Get-ChildItem -Path $root -Filter "*.csproj" -Recurse -Depth 2 -ErrorAction SilentlyContinue | - Select-Object -First 1 - if ($project) { - Write-Host "ENTRY_TYPE:Project"; Write-Host "ENTRY:$($project.FullName)" - } else { - Write-Host "ENTRY_TYPE:NotFound" - } -} - -# Test projects: search path first, then git root, then parent -$searchRoots = @($root) -$gitRoot = (git -C $root rev-parse --show-toplevel 2>$null) -if ($gitRoot) { $gitRoot = [System.IO.Path]::GetFullPath($gitRoot) } -if ($gitRoot -and $gitRoot -ne $root) { $searchRoots += $gitRoot } -$parentPath = Split-Path $root -Parent -if ($parentPath -and $parentPath -ne $root -and $parentPath -ne $gitRoot) { $searchRoots += $parentPath } - -$testProjects = @() -foreach ($sr in $searchRoots) { - # Primary: match by .csproj content (test framework references) - $testProjects = @(Get-ChildItem -Path $sr -Filter "*.csproj" -Recurse -Depth 5 -ErrorAction SilentlyContinue | - Where-Object { $_.FullName -notmatch '([/\\]obj[/\\]|[/\\]bin[/\\])' } | - Where-Object { (Select-String -Path $_.FullName -Pattern 'Microsoft\.NET\.Test\.Sdk|xunit|nunit|MSTest\.TestAdapter|"MSTest"|MSTest\.TestFramework|TUnit' -Quiet) }) - if ($testProjects.Count -gt 0) { - if ($sr -ne $root) { Write-Host "SEARCHED:$sr" } - break - } -} - -# Fallback: match by file name convention -if ($testProjects.Count -eq 0) { - foreach ($sr in $searchRoots) { - $testProjects = @(Get-ChildItem -Path $sr -Filter "*.csproj" -Recurse -Depth 5 -ErrorAction SilentlyContinue | - Where-Object { $_.Name -match '(?i)(test|spec)' }) - if ($testProjects.Count -gt 0) { - if ($sr -ne $root) { Write-Host "SEARCHED:$sr" } - break - } - } -} -Write-Host "TEST_PROJECTS:$($testProjects.Count)" -$testProjects | ForEach-Object { Write-Host "TEST_PROJECT:$($_.FullName)" } - -# Resolve the test output root (where coverage-analysis artifacts will be written) -if ($testProjects.Count -eq 0) { - if ($gitRoot) { - $testOutputRoot = $gitRoot - } else { - $testOutputRoot = $root - } -} elseif ($testProjects.Count -eq 1) { - $testOutputRoot = $testProjects[0].DirectoryName -} else { - # Multiple test projects — find their deepest common parent directory - $dirs = $testProjects | ForEach-Object { $_.DirectoryName } - $common = $dirs[0] - foreach ($d in $dirs[1..($dirs.Count-1)]) { - $sep = [System.IO.Path]::DirectorySeparatorChar - while (-not $d.StartsWith("$common$sep", [System.StringComparison]::OrdinalIgnoreCase) -and $d -ne $common) { - $prevCommon = $common - $common = Split-Path $common -Parent - # Terminate if we can no longer move up (at filesystem root or no parent) - if ([string]::IsNullOrEmpty($common) -or $common -eq $prevCommon) { - $common = $null - break - } - } - } - if ([string]::IsNullOrEmpty($common)) { - # Fallback when no common parent directory exists (e.g., projects on different drives) - if ($gitRoot) { - $testOutputRoot = $gitRoot - } else { - $testOutputRoot = $root - } - } else { - $testOutputRoot = $common - } -} -Write-Host "TEST_OUTPUT_ROOT:$testOutputRoot" -``` - -- If `ENTRY_TYPE:NotFound` and test projects were found → use the test projects directly as entry points (run `dotnet test` on each test `.csproj`). -- If `ENTRY_TYPE:NotFound` and no test projects found → stop: `No .sln or test projects found under . Provide the path to your .NET solution or project.` -- If `TEST_PROJECTS:0` and `EXISTING_COBERTURA_COUNT` > 0 (Step 2b) → continue with existing Cobertura XML analysis (no `dotnet test` run). -- If `TEST_PROJECTS:0` and `EXISTING_COBERTURA_COUNT` == 0 → stop: `No test projects found (expected projects with 'Test' or 'Spec' in the name), and no existing Cobertura XML was provided. Add a test project or provide a Cobertura file path.` - -#### Step 2: Create the output directory - -```powershell -$coverageDir = Join-Path $testOutputRoot "TestResults" "coverage-analysis" -if (Test-Path $coverageDir) { Remove-Item $coverageDir -Recurse -Force } -New-Item -ItemType Directory -Path $coverageDir -Force | Out-Null -Write-Host "COVERAGE_DIR:$coverageDir" -``` - -This step only manages the `TestResults/coverage-analysis/` subdirectory (skill-owned outputs). It must never delete user-supplied Cobertura files — those live one level up at `TestResults/coverage.cobertura.xml` (or wherever the user pointed). If the user provided a path that *is* `TestResults/coverage-analysis/...`, copy the file aside before this step recreates the directory. - -#### Step 2b: Discover or accept existing Cobertura XML (required for the existing-data path) - -If the user supplied a Cobertura XML path explicitly, use it. Otherwise probe well-known locations and any path the user mentioned: - -```powershell -# 1. Honor a user-supplied path first (highest priority) -$coberturaFiles = @() -if ($userSuppliedCoberturaPath -and (Test-Path $userSuppliedCoberturaPath)) { - $coberturaFiles = @(Get-Item $userSuppliedCoberturaPath) -} - -# 2. Otherwise scan TestResults/ at the repo/test root for any *.cobertura.xml -if ($coberturaFiles.Count -eq 0) { - $searchPaths = @( - (Join-Path $testOutputRoot "TestResults"), - (Join-Path $root "TestResults") - ) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -Unique - foreach ($sp in $searchPaths) { - $found = @(Get-ChildItem -Path $sp -Filter "*.cobertura.xml" -Recurse -ErrorAction SilentlyContinue | - Where-Object { $_.FullName -notmatch '[/\\]coverage-analysis[/\\]raw[/\\]' }) - if ($found.Count -gt 0) { $coberturaFiles = $found; break } - } -} - -Write-Host "EXISTING_COBERTURA_COUNT:$($coberturaFiles.Count)" -$coberturaFiles | ForEach-Object { Write-Host "EXISTING_COBERTURA:$($_.FullName)" } -``` - -- If `EXISTING_COBERTURA_COUNT` > 0 → **skip Phase 2 entirely** and pass these paths to the Phase 3 scripts. -- If `EXISTING_COBERTURA_COUNT` == 0 → run Phase 2 to generate fresh coverage; the file paths to feed Phase 3 will be discovered from `/raw/` after `dotnet test`. - -#### Step 2c: Recommend ignoring `TestResults/` - -```powershell -$pattern = "**/TestResults/" -$gitRoot = (git -C $testOutputRoot rev-parse --show-toplevel 2>$null) -if ($gitRoot) { $gitRoot = [System.IO.Path]::GetFullPath($gitRoot) } -if ($gitRoot) { - $gitignorePath = Join-Path $gitRoot ".gitignore" - $alreadyIgnored = $false - if (Test-Path $gitignorePath) { - $alreadyIgnored = (Select-String -Path $gitignorePath -Pattern '^\s*(\*\*/)?TestResults/?\s*$' -Quiet) - } - if ($alreadyIgnored) { - Write-Host "GITIGNORE_RECOMMENDATION:already-present" - } else { - Write-Host "GITIGNORE_RECOMMENDATION:$pattern" - } -} else { - Write-Host "GITIGNORE_RECOMMENDATION:$pattern" -} -``` - -### Phase 2 — Test execution (skip when Cobertura XML already exists) - -Run only when no Cobertura XML is present. If the user already has coverage data, skip directly to Phase 3. - -#### Step 3: Detect coverage provider and run `dotnet test` with coverage collection - -Before running tests, detect which coverage provider the test projects use. Projects may reference -`Microsoft.Testing.Extensions.CodeCoverage` (Microsoft's built-in provider, common on .NET 9+) or -`coverlet.collector` (open-source, the default in xUnit templates). The provider determines which -`dotnet test` arguments to use — both produce Cobertura XML. - -```powershell -# Detect coverage provider per test project -$coverageProvider = "unknown" # will be set to "ms-codecoverage" or "coverlet" -$msCodeCovProjects = @() -$coverletProjects = @() -$neitherProjects = @() - -foreach ($tp in $testProjects) { - $hasMsCodeCov = Select-String -Path $tp.FullName -Pattern 'Microsoft\.Testing\.Extensions\.CodeCoverage' -Quiet - $hasCoverlet = Select-String -Path $tp.FullName -Pattern 'coverlet\.collector' -Quiet - if ($hasMsCodeCov) { $msCodeCovProjects += $tp } - elseif ($hasCoverlet) { $coverletProjects += $tp } - else { $neitherProjects += $tp } -} - -# Determine the provider strategy -if ($msCodeCovProjects.Count -gt 0 -and $coverletProjects.Count -eq 0) { - $coverageProvider = "ms-codecoverage" - Write-Host "COVERAGE_PROVIDER:ms-codecoverage (ms:$($msCodeCovProjects.Count), none:$($neitherProjects.Count))" -} elseif ($coverletProjects.Count -gt 0 -and $msCodeCovProjects.Count -eq 0) { - $coverageProvider = "coverlet" - Write-Host "COVERAGE_PROVIDER:coverlet (coverlet:$($coverletProjects.Count), none:$($neitherProjects.Count))" -} elseif ($msCodeCovProjects.Count -gt 0 -and $coverletProjects.Count -gt 0) { - $coverageProvider = "mixed-project" - Write-Host "COVERAGE_PROVIDER:mixed-project (ms:$($msCodeCovProjects.Count), coverlet:$($coverletProjects.Count), none:$($neitherProjects.Count))" -} else { - $coverageProvider = "coverlet" - Write-Host "COVERAGE_PROVIDER:none-detected — defaulting to coverlet" -} -``` - -If any discovered test projects have no provider, add one based on the selected strategy: - -```powershell -if ($coverageProvider -eq "ms-codecoverage" -and $neitherProjects.Count -gt 0) { - Write-Host "ADDING_MS_CODECOVERAGE:$($neitherProjects.Count) project(s)" - foreach ($tp in $neitherProjects) { - dotnet add $tp.FullName package Microsoft.Testing.Extensions.CodeCoverage --no-restore - Write-Host " ADDED_MS_CODECOVERAGE:$($tp.FullName)" - } - foreach ($tp in $neitherProjects) { - dotnet restore $tp.FullName --quiet - } -} - -if (($coverageProvider -eq "coverlet" -or $coverageProvider -eq "mixed-project") -and $neitherProjects.Count -gt 0) { - Write-Host "ADDING_COVERLET:$($neitherProjects.Count) project(s)" - foreach ($tp in $neitherProjects) { - dotnet add $tp.FullName package coverlet.collector --no-restore - Write-Host " ADDED:$($tp.FullName)" - } - foreach ($tp in $neitherProjects) { - dotnet restore $tp.FullName --quiet - } -} -``` - -Log each addition to the console so the developer sees what changed. Document the additions in the final report (see Output Format). - -Run one `dotnet test` per entry point for the selected strategy: - -- In `ms-codecoverage` or `coverlet` mode: run a single command for the solution entry (or one per test project if no `.sln` was found). -- In `mixed-project` mode: run one command per test project, using that project's existing provider to avoid dual-provider conflicts. - -**Coverlet** (`coverlet.collector`): - -```powershell -$rawDir = Join-Path "" "raw" -dotnet test "" ` - --collect:"XPlat Code Coverage" ` - --results-directory $rawDir ` - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura ` - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Include="[*]*" ` - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Exclude="[*.Tests]*,[*.Test]*,[*Tests]*,[*Test]*,[*.Specs]*,[*.Testing]*" ` - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.SkipAutoProps=true -``` - -**Microsoft CodeCoverage** (`Microsoft.Testing.Extensions.CodeCoverage`): - -The command syntax depends on the .NET SDK version. In .NET 9, Microsoft.Testing.Platform arguments -must be passed after the `--` separator. In .NET 10+, `--coverage` is a top-level `dotnet test` flag. - -```powershell -$rawDir = Join-Path "" "raw" - -# Detect SDK version for correct argument placement -$sdkVersion = (dotnet --version 2>$null) -$major = if ($sdkVersion -match '^(\d+)\.') { [int]$Matches[1] } else { 9 } - -if ($major -ge 10) { - # .NET 10+: --coverage is a first-class dotnet test flag - dotnet test "" ` - --results-directory $rawDir ` - --coverage ` - --coverage-output-format cobertura ` - --coverage-output $rawDir -} else { - # .NET 9: pass Microsoft.Testing.Platform arguments after the -- separator - dotnet test "" ` - --results-directory $rawDir ` - -- --coverage --coverage-output-format cobertura --coverage-output $rawDir -} -``` - -**Mixed-project mode** (`Microsoft.Testing.Extensions.CodeCoverage` + `coverlet.collector` in the same solution): - -```powershell -$rawDir = Join-Path "" "raw" -$sdkVersion = (dotnet --version 2>$null) -$major = if ($sdkVersion -match '^(\d+)\.') { [int]$Matches[1] } else { 9 } - -foreach ($tp in $testProjects) { - $hasMsCodeCov = Select-String -Path $tp.FullName -Pattern 'Microsoft\.Testing\.Extensions\.CodeCoverage' -Quiet - if ($hasMsCodeCov) { - if ($major -ge 10) { - dotnet test $tp.FullName --results-directory $rawDir --coverage --coverage-output-format cobertura --coverage-output $rawDir - } else { - dotnet test $tp.FullName --results-directory $rawDir -- --coverage --coverage-output-format cobertura --coverage-output $rawDir - } - } else { - dotnet test $tp.FullName ` - --collect:"XPlat Code Coverage" ` - --results-directory $rawDir ` - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura ` - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Include="[*]*" ` - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Exclude="[*.Tests]*,[*.Test]*,[*Tests]*,[*Test]*,[*.Specs]*,[*.Testing]*" ` - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.SkipAutoProps=true - } -} -``` - -Exit code handling: - -- **0** — all tests passed, coverage collected -- **1** — some tests failed (coverage still collected — proceed with a warning) -- **Other** — build failure; stop and report the error - -After the run, locate coverage files: - -```powershell -$coberturaFiles = Get-ChildItem -Path (Join-Path "" "raw") -Filter "coverage.cobertura.xml" -Recurse -Write-Host "COBERTURA_COUNT:$($coberturaFiles.Count)" -$coberturaFiles | ForEach-Object { Write-Host "COBERTURA:$($_.FullName)" } -$vsCovFiles = Get-ChildItem -Path (Join-Path "" "raw") -Filter "*.coverage" -Recurse -ErrorAction SilentlyContinue -if ($vsCovFiles) { Write-Host "VS_BINARY_COVERAGE:$($vsCovFiles.Count)" } -``` - -If `COBERTURA_COUNT` is 0: - -- If `VS_BINARY_COVERAGE` > 0: warn the user — *"Found .coverage files (VS binary format) but no Cobertura XML. These were likely produced by Visual Studio's built-in collector, which outputs a binary format by default. This skill needs Cobertura XML. Re-running with the detected provider configured for Cobertura output."* Then re-run the appropriate `dotnet test` command above (Coverlet or Microsoft CodeCoverage) with Cobertura format. -- If no `.coverage` files either: stop and report — *"Coverage files not generated. Ensure `dotnet test` completed successfully and check the build output for errors."* - -### Phase 3 — Analysis (sequential) - -Run the two bundled PowerShell scripts. Both are cheap and complete in seconds. **Do not** install or invoke ReportGenerator here — that belongs in optional Phase 5, after the user-facing summary has been delivered. - -#### Step 4: Calculate CRAP scores using the bundled script - -Run `scripts/Compute-CrapScores.ps1` (co-located with this SKILL.md). It reads all Cobertura XML files, applies `CRAP(m) = comp² × (1 − cov)³ + comp` per method, and returns the top-N hotspots as JSON. - -To locate the script: find the directory containing this skill's `SKILL.md` file (the skill loader provides this context), then resolve `scripts/Compute-CrapScores.ps1` relative to it. If the script path cannot be determined, calculate CRAP scores inline using the formula below. - -```powershell -& "/scripts/Compute-CrapScores.ps1" ` - -CoberturaPath @() ` - -CrapThreshold ` - -TopN -``` - -Script outputs: `OVERALL_LINE_COVERAGE:`, `OVERALL_BRANCH_COVERAGE:` (aggregated project-wide rates across all provided Cobertura files), `TOTAL_METHODS:`, `FLAGGED_METHODS:`, `HOTSPOTS:` (top-N sorted by CrapScore descending). The OVERALL_* values are exactly what the Phase 4 summary needs for the "Line Coverage" / "Branch Coverage" rows — no separate XML parsing tool call is required. - -#### Step 5: Extract per-method coverage gaps - -Run `scripts/Extract-MethodCoverage.ps1` to get per-method coverage data for the Coverage Gaps table: - -```powershell -& "/scripts/Extract-MethodCoverage.ps1" ` - -CoberturaPath @() ` - -CoverageThreshold ` - -BranchThreshold ` - -Filter below-threshold -``` - -Script outputs: JSON array of methods below the coverage threshold, sorted by coverage ascending. Use this data to populate the Coverage Gaps by File table in the report. - -### Phase 4 — User-facing summary (MANDATORY — your next assistant response) - -As soon as Phase 3 completes, **your immediately next assistant response must contain the user-facing analysis** — do not interleave any other tool calls before it. This is the response the user (and any judge) sees. Skipping or deferring this in favor of Phase 5 (ReportGenerator) is a hard failure. - -The response must include, at minimum: - -1. Overall line and branch coverage — read directly from the `OVERALL_LINE_COVERAGE:` / `OVERALL_BRANCH_COVERAGE:` lines emitted by `Compute-CrapScores.ps1` (no extra Cobertura parsing required) -2. The Risk Hotspots table built from `Compute-CrapScores.ps1` `HOTSPOTS:` output (CRAP scores, complexity, coverage) -3. Identification of the highest-risk method(s) and what is blocking coverage -4. 1–3 prioritized, specific recommendations (which method to test, expected CRAP/coverage impact) - -Use `references/output-format.md` verbatim for fixed headings, table structures, symbols, and emoji. Use `references/guidelines.md` for prioritization rules and style. - -If Phase 5 has not yet run when you compose this summary, mark the `## 📁 Reports` section's HTML/Text/CSV/GitHub-markdown rows as `Not generated (optional — request HTML reports to enable)`. Only the `coverage-analysis.md` and raw Cobertura paths are guaranteed to exist. - -Attempt to save the same content to `TestResults/coverage-analysis/coverage-analysis.md` before delivering the response (use the editor's create/edit tool — do not shell out). If the file write fails, still deliver the summary and note the file-write failure explicitly. - -### Phase 5 — Optional: ReportGenerator HTML/CSV reports (post-summary) - -Phase 5 is **strictly optional** and runs **only after** Phase 4 has been delivered. Skip Phase 5 entirely when: - -- The user supplied existing Cobertura XML and only asked for analysis (the default for the existing-data path). -- The user is diagnosing a coverage plateau or asking "what's blocking me?" — they want the answer, not a static-site report. -- ReportGenerator is not already installed and you have no clear signal the user wants HTML reports. - -Run Phase 5 only when the user explicitly asks for HTML/CSV reports, or when the project flow requires them (e.g., a CI artifact upload step). - -#### Step 6: Verify or install ReportGenerator (only if running Phase 5) - -```powershell -$rgAvailable = $false -$rgCommand = Get-Command reportgenerator -ErrorAction SilentlyContinue -if ($rgCommand) { - $rgAvailable = $true - Write-Host "RG_INSTALLED:already-present" -} else { - $rgToolPath = Join-Path "" ".tools" - dotnet tool install dotnet-reportgenerator-globaltool --tool-path $rgToolPath - if ($LASTEXITCODE -eq 0) { - $env:PATH = "$rgToolPath$([System.IO.Path]::PathSeparator)$env:PATH" - $rgCommand = Get-Command reportgenerator -ErrorAction SilentlyContinue - if ($rgCommand) { - $rgAvailable = $true - Write-Host "RG_INSTALLED:true (tool-path: $rgToolPath)" - } else { - Write-Host "RG_INSTALLED:false" - Write-Host "RG_INSTALL_ERROR:reportgenerator-not-available" - } - } else { - Write-Host "RG_INSTALLED:false" - Write-Host "RG_INSTALL_ERROR:reportgenerator-not-available" - } -} -Write-Host "RG_AVAILABLE:$rgAvailable" -``` - -If installation fails (no internet), keep `RG_AVAILABLE:false`, leave the existing user-facing summary as the final output, and note that HTML reports were skipped. - -#### Step 7: Generate HTML/CSV reports - -```powershell -$reportsDir = Join-Path "" "reports" -if ($rgAvailable) { - reportgenerator ` - -reports:"" ` - -targetdir:$reportsDir ` - -reporttypes:"Html;TextSummary;MarkdownSummaryGithub;CsvSummary" ` - -title:"Coverage Report" ` - -tag:"coverage-analysis-skill" - - Get-Content (Join-Path $reportsDir "Summary.txt") -ErrorAction SilentlyContinue -} else { - Write-Host "REPORTGENERATOR_SKIPPED:true" -} -``` - -After Phase 5 completes successfully, you may follow up with a short message pointing the user to the generated HTML report (one paragraph, no need to repeat the summary). - -## Validation - -- Verify that at least one `coverage.cobertura.xml` file was generated after `dotnet test` (or already exists when the user supplied one) -- Confirm the assistant response contained the CRAP/risk-hotspot table — saving the markdown file is secondary -- Confirm `TestResults/coverage-analysis/coverage-analysis.md` was written and contains data -- Spot-check one method's CRAP score: `comp² × (1 − cov)³ + comp` — a method with 100% coverage should have CRAP = complexity -- If Phase 5 ran, verify `TestResults/coverage-analysis/reports/index.html` exists; otherwise the report file should mark HTML/Text/CSV rows as `Not generated` - -## Common Pitfalls - -- **No Cobertura XML generated** — the test project may lack a coverage provider. The skill auto-adds one, but if `dotnet add package` fails (offline/proxy), coverage collection silently produces nothing. Check for `.coverage` binary files as a fallback indicator. -- **Test failures (exit code 1)** — coverage is still collected from passing tests. Do not abort; proceed with partial data and note the failures in the summary. -- **Premature end before user-facing summary** — never start Phase 5 (ReportGenerator install/run) before the Phase 4 assistant response is delivered. The heavy `dotnet tool install` can crash the session or exhaust budget, leaving the user with no analysis even though the CRAP scores were already computed. -- **ReportGenerator install failure** — if `dotnet tool install` fails (no internet) during Phase 5, leave the existing Phase 4 summary as the final output and note that HTML reports were skipped. Do not retry or block on the install. -- **Method name mismatches in Cobertura** — async methods, lambdas, and local functions may have compiler-generated names. The scripts use the Cobertura method name/signature directly; verify against source if results look unexpected. -- **Mixed coverage providers** — when a solution contains both Coverlet and Microsoft CodeCoverage projects, the skill runs per-project to avoid dual-provider conflicts. This is slower but correct. diff --git a/.agents/skills/coverage-analysis/references/guidelines.md b/.agents/skills/coverage-analysis/references/guidelines.md deleted file mode 100644 index 344f69e..0000000 --- a/.agents/skills/coverage-analysis/references/guidelines.md +++ /dev/null @@ -1,59 +0,0 @@ -# Guidelines - -**Don't modify source or production code.** The only permitted project file modifications are adding a coverage provider package to test projects that currently have no provider: `coverlet.collector` (coverlet/mixed modes) or `Microsoft.Testing.Extensions.CodeCoverage` (ms-codecoverage mode). Do not add a second provider to projects that already have one. Always log package additions and document revert commands in the report. Write all other output to `TestResults/coverage-analysis/` under the test project directory. - -**Always show and open the generated markdown report — but only after the assistant response with the CRAP/risk-hotspot summary has been delivered.** Saving and opening `TestResults/coverage-analysis/coverage-analysis.md` is a follow-up action; it must never delay the user-facing summary. - -**Don't generate new tests during the initial analysis run.** This skill surfaces where tests are needed. Test generation is a separate follow-up step outside the scope of this skill. - -**Use inline `dotnet test` arguments, not runsettings files.** Runsettings files require the developer to already know what they're doing — the whole point of this skill is that they shouldn't have to. Inline data collector args produce the same result with zero configuration. - -**Show the risk hotspots table even when all thresholds pass.** A project at 90% line coverage can still have a method with cyclomatic complexity 20 and 0% branch coverage. The thresholds measure averages; the hotspot table finds outliers. Don't hide it just because the summary looks green. - -**Always compute and surface CRAP scores.** The Risk Hotspots table is mandatory in every analysis output, whether analyzing pre-existing data, freshly collected data, or diagnosing a plateau. Never skip CRAP score computation — it is the primary differentiator between this skill and raw `dotnet test` coverage output. - -**Continue past test failures (exit code 1).** If some tests fail, coverage is still collected from the passing tests — partial data is better than no data. Note the failures in the summary and proceed. Aborting would leave the developer with nothing actionable. - -**Run `dotnet test` only once per entry point during normal flow.** When a solution is found, run it once against the solution. When no solution is found, run it once per test project. A single recovery rerun is allowed only if the first run produced no Cobertura XML and only `.coverage` binary output. - -**CRAP threshold of 30 is the default for a reason.** Scores above 30 are widely cited (by the original researchers) as "needs immediate attention." Scores between 15 and 30 are moderate — flag them in the table but don't make them sound catastrophic. Scores ≤ 5 are generally fine. - -**Priority assignment for coverage gaps:** - -- **HIGH** — file has both a CRAP score above threshold AND coverage below threshold (the double failure is what makes it urgent) -- **MED** — coverage below threshold OR CRAP score above threshold, but not both -- **LOW** — coverage below threshold with all methods having complexity ≤ 2 (trivial code — missing coverage here is unlikely to hide real bugs) - ---- - -## Coverage Intelligence — Going Beyond the Numbers - -**Prioritize uncovered code that is** complex (cyclomatic complexity > 5), on critical paths (auth, payment, data access, error handling), or changed frequently. **Deprioritize** trivial getters (complexity 1–2), generated files (EF migrations, `*.Designer.cs`, `*.g.cs`), and DI/configuration glue code. - -**Coverage plateau diagnosis** — if coverage has stopped increasing, check for: `[Exclude]` attributes hiding large code sections, tests that execute code but assert nothing (inflated coverage without verification), or integration code that needs external dependencies (databases, file system). - -**AI-generated test quality** — coverage delta alone is insufficient. Flag methods where CRAP score is still above threshold after coverage increased (tests may be happy-path only), and methods covered by a single test with no branch variation. - ---- - -## Style - -- **Keep risk hotspots prominent and immediately after the summary section** — developers should find the highest-risk methods quickly -- **Quantify recommendations** — "adding 3 tests for `ProcessOrder` would cut the CRAP score from 48 to ~6" -- **Be direct** — skip preamble, get to the table -- **Emoji for visual scanning in generated output** (defined in `references/output-format.md`): - - | Symbol | Meaning | - |--------|---------| - | 🔥 | hotspots | - | 📋 | gaps | - | 💡 | recommendations | - | 📁 | reports | - | ✅ | passing | - | ❌ | failing | - | ⚠️ | warning | - | 🔴 | HIGH priority | - | 🟡 | MED priority | - | 🟢 | LOW priority | - -- **Always use Unicode emoji in generated output** — never shortcodes like `:x:` or `:fire:` diff --git a/.agents/skills/coverage-analysis/references/output-format.md b/.agents/skills/coverage-analysis/references/output-format.md deleted file mode 100644 index 7e3c5b6..0000000 --- a/.agents/skills/coverage-analysis/references/output-format.md +++ /dev/null @@ -1,87 +0,0 @@ -# Output Format - -Copy the template below **verbatim** for all fixed elements (headings, table headers, emoji, symbols). Only replace `` values with actual data. Do not substitute emoji with text equivalents, do not change `·` to `-`, do not change `×` to `x`, and do not drop section emoji prefixes. - -```markdown -# Coverage Analysis - - -| Metric | Value | -|--------|-------| -| **Date** | | -| **Line Coverage** | % | -| **Branch Coverage** | % | -| **Risk Hotspots** | (CRAP > ) | -| **Tests** | passed · failed | - -## Summary - -| Metric | Value | Threshold | Status | -|--------|-------|-----------|--------| -| **Line Coverage** | % | % | ✅ / ❌ | -| **Branch Coverage** | % | % | ✅ / ❌ | -| **Methods Analyzed** | | — | — | -| **Risk Hotspots** | | 0 | ✅ / ⚠️ | -| **Test Result** | | — | ✅ / ⚠️ | - -> Coverage collected from ** of test project(s)**. -> Outputs saved to: `/` (markdown summary + raw Cobertura XML). -> *If Phase 5 ran:* HTML/CSV reports also at `/reports/`. - -If any coverage provider package was added to test projects, include this note after the summary: - -> ℹ️ **Coverage provider package updates** -> - `coverlet.collector` added to `` project(s): ``, `` -> - `Microsoft.Testing.Extensions.CodeCoverage` added to `` project(s): `` -> -> To revert: `git checkout -- ` - -If all test projects already had a coverage provider, omit this note. - ---- - -## 🔥 Risk Hotspots (Top by CRAP Score) - -Methods flagged as high-risk: complex code with low test coverage that is dangerous to change. - -| Rank | Method | Class | File | Complexity | Coverage | CRAP Score | -|------|--------|-------|------|-----------|---------|-----------| -| 1 | `` | `` | `` | | % | **** | -| … | … | … | … | … | … | … | - -> **CRAP Score** = `Complexity² × (1 − Coverage)³ + Complexity`. -> Scores above are flagged. A score ≤ 5 is considered safe. - ---- - -## 📋 Coverage Gaps by File - -Files below the line or branch coverage threshold, ordered by uncovered lines descending: - -| File | Line Coverage | Branch Coverage | Uncovered Lines | Priority | -|------|--------------|----------------|----------------|---------| -| `` | % | % | | 🔴 HIGH / 🟡 MED / 🟢 LOW | -| … | … | … | … | … | - ---- - -## 💡 Recommendations - -1. **Write tests for the top risk hotspot first** — `` in `` has a CRAP score of (complexity , % coverage). Reducing it to 80% coverage would drop the score to ~. -2. **Focus on ``** — uncovered lines, below threshold. -3. **** - ---- - -## 📁 Reports - -| Report | Path | -|--------|------| -| Markdown summary (this file) | `/coverage-analysis.md` | -| Raw Cobertura XML | `` | -| HTML (browsable) | `/reports/index.html` *or* `Not generated (optional — request HTML reports to enable)` | -| Text summary | `/reports/Summary.txt` *or* `Not generated` | -| GitHub markdown | `/reports/SummaryGithub.md` *or* `Not generated` | -| CSV data | `/reports/Summary.csv` *or* `Not generated` | -``` - -If ReportGenerator (Phase 5) has not run, mark the HTML/Text/GitHub-markdown/CSV rows as `Not generated (optional — request HTML reports to enable)`. Do not invent paths for files that have not been produced. For **Raw Cobertura XML**, list the actual XML file path(s) used in analysis (for from-scratch runs this is typically under `/raw/`; for existing-data runs this may be under `TestResults/` or another user-supplied location). diff --git a/.agents/skills/coverage-analysis/scripts/Compute-CrapScores.ps1 b/.agents/skills/coverage-analysis/scripts/Compute-CrapScores.ps1 deleted file mode 100644 index b0c8d9f..0000000 --- a/.agents/skills/coverage-analysis/scripts/Compute-CrapScores.ps1 +++ /dev/null @@ -1,165 +0,0 @@ -# Compute-CrapScores.ps1 -# -# Reads a Cobertura XML coverage file and calculates CRAP scores per method. -# Uses Alberto Savoia's original CRAP formula: -# CRAP(m) = comp(m)^2 * (1 - cov(m))^3 + comp(m) -# -# Usage: -# .\Compute-CrapScores.ps1 -CoberturaPath ,,... [-CrapThreshold ] [-TopN ] -# -# Outputs: -# - OVERALL_LINE_COVERAGE: (aggregate line coverage across input files, as percent) -# - OVERALL_BRANCH_COVERAGE: (aggregate branch coverage across input files, as percent) -# - TOTAL_METHODS: -# - FLAGGED_METHODS: -# - HOTSPOTS: (top N by CRAP score) - -param( - [Parameter(Mandatory)][string[]]$CoberturaPath, - [int]$CrapThreshold = 30, - [int]$TopN = 10 -) - -# Merge methods across all Cobertura files using a stable key (Class|Method|Signature|File). -# Line hits are accumulated so a line is counted as covered if any input coverage file covered it. -$methodMap = @{} -$overallLineRate = 0.0 -$overallBranchRate = 0.0 -$totalLinesCovered = 0 -$totalLinesValid = 0 -$totalBranchesCovered = 0 -$totalBranchesValid = 0 -$fallbackLineRates = [System.Collections.Generic.List[double]]::new() -$fallbackBranchRates = [System.Collections.Generic.List[double]]::new() - -foreach ($filePath in $CoberturaPath) { - if (-not (Test-Path $filePath)) { - Write-Error "Cobertura file not found: $filePath" - exit 2 - } - - try { - [xml]$cobertura = Get-Content $filePath -Encoding UTF8 -ErrorAction Stop - } catch { - Write-Error "Failed to parse Cobertura XML: $filePath. $_" - exit 2 - } - - # Prefer aggregate numerator/denominator attributes when present. - if ($null -ne $cobertura.coverage.'lines-covered' -and $null -ne $cobertura.coverage.'lines-valid') { - $totalLinesCovered += [double]$cobertura.coverage.'lines-covered' - $totalLinesValid += [double]$cobertura.coverage.'lines-valid' - } elseif ($cobertura.coverage.'line-rate') { - $fallbackLineRates.Add([double]$cobertura.coverage.'line-rate') - } - if ($null -ne $cobertura.coverage.'branches-covered' -and $null -ne $cobertura.coverage.'branches-valid') { - $totalBranchesCovered += [double]$cobertura.coverage.'branches-covered' - $totalBranchesValid += [double]$cobertura.coverage.'branches-valid' - } elseif ($cobertura.coverage.'branch-rate') { - $fallbackBranchRates.Add([double]$cobertura.coverage.'branch-rate') - } - - foreach ($package in $cobertura.coverage.packages.package) { - foreach ($class in $package.classes.class) { - $className = $class.name - $fileName = $class.filename - - foreach ($method in $class.methods.method) { - $key = "$className|$($method.name)|$($method.signature)|$fileName" - - # Cyclomatic complexity is stored as an XML attribute in Cobertura format - $complexity = if ($null -ne $method.complexity) { [int]$method.complexity } else { 1 } - if ($complexity -lt 1) { $complexity = 1 } - - if (-not $methodMap.ContainsKey($key)) { - $methodMap[$key] = @{ - Class = $className - Method = $method.name - Signature = $method.signature - File = $fileName - Complexity = $complexity - LineHits = @{} - } - } - - # Accumulate hit counts per line number across files - foreach ($line in $method.lines.line) { - $lineNo = $line.number - $hits = [int]$line.hits - if ($methodMap[$key].LineHits.ContainsKey($lineNo)) { - $methodMap[$key].LineHits[$lineNo] += $hits - } else { - $methodMap[$key].LineHits[$lineNo] = $hits - } - } - } - } - } -} - -$results = [System.Collections.Generic.List[PSCustomObject]]::new() - -foreach ($entry in $methodMap.Values) { - $totalLines = $entry.LineHits.Count - $coveredLines = ($entry.LineHits.Values | Where-Object { $_ -gt 0 } | Measure-Object).Count - $lineCoverage = if ($totalLines -gt 0) { $coveredLines / $totalLines } else { 0.0 } - - $complexity = $entry.Complexity - - # Alberto Savoia's CRAP formula: comp^2 * (1 - cov)^3 + comp - # The cubic exponent on (1-cov) sharply penalizes low coverage: - # at 0% coverage the risk multiplier is 1.0; at 50% it drops to 0.125. - # Higher scores = more complex AND less covered = riskier to change - $uncovered = 1.0 - $lineCoverage - $crapScore = [Math]::Round(($complexity * $complexity * [Math]::Pow($uncovered, 3)) + $complexity, 2) - - $results.Add([PSCustomObject]@{ - Class = $entry.Class - Method = $entry.Method - Signature = $entry.Signature - File = $entry.File - TotalLines = $totalLines - CoveredLines = $coveredLines - LineCoverage = [Math]::Round($lineCoverage * 100, 1) - Complexity = $complexity - CrapScore = $crapScore - }) -} - -$hotspots = $results | Sort-Object CrapScore -Descending | Select-Object -First $TopN -$flagged = $results | Where-Object { $_.CrapScore -gt $CrapThreshold } - -if ($totalLinesValid -gt 0) { - $overallLineRate = $totalLinesCovered / $totalLinesValid -} else { - # Fallback approximation when Cobertura aggregate counters and per-file rates are unavailable. - # This uses merged method line totals and may under/over-estimate if Cobertura - # includes executable lines outside method nodes. - $mergedTotalLines = ($results | Measure-Object -Property TotalLines -Sum).Sum - $mergedCoveredLines = ($results | Measure-Object -Property CoveredLines -Sum).Sum - if ($mergedTotalLines -gt 0) { - $overallLineRate = [double]$mergedCoveredLines / [double]$mergedTotalLines - } elseif ($fallbackLineRates.Count -gt 0) { - $overallLineRate = ($fallbackLineRates | Measure-Object -Average).Average - } else { - $overallLineRate = 0.0 - } -} - -if ($totalBranchesValid -gt 0) { - $overallBranchRate = $totalBranchesCovered / $totalBranchesValid -} elseif ($fallbackBranchRates.Count -gt 0) { - $overallBranchRate = ($fallbackBranchRates | Measure-Object -Average).Average -} else { - $overallBranchRate = 0.0 -} - -Write-Host "OVERALL_LINE_COVERAGE:$([Math]::Round($overallLineRate * 100, 1))" -Write-Host "OVERALL_BRANCH_COVERAGE:$([Math]::Round($overallBranchRate * 100, 1))" -Write-Host "TOTAL_METHODS:$($results.Count)" -Write-Host "FLAGGED_METHODS:$($flagged.Count)" -if ($hotspots) { - Write-Output "HOTSPOTS:$(@($hotspots) | ConvertTo-Json -Compress)" -} else { - Write-Output "HOTSPOTS:[]" -} diff --git a/.agents/skills/coverage-analysis/scripts/Extract-MethodCoverage.ps1 b/.agents/skills/coverage-analysis/scripts/Extract-MethodCoverage.ps1 deleted file mode 100644 index 999a827..0000000 --- a/.agents/skills/coverage-analysis/scripts/Extract-MethodCoverage.ps1 +++ /dev/null @@ -1,193 +0,0 @@ -param( - [Parameter(Mandatory=$true)] - [string[]]$CoberturaPath, - - [Parameter(Mandatory=$false)] - [int]$CoverageThreshold = 80, - - [Parameter(Mandatory=$false)] - [int]$BranchThreshold = 70, - - [Parameter(Mandatory=$false)] - [ValidateSet('uncovered', 'below-threshold', 'all')] - [string]$Filter = 'all' -) - -<# -.SYNOPSIS -Extract method-level coverage from Cobertura XML and output as JSON. - -.DESCRIPTION -Parses one or more Cobertura code coverage XML files and extracts per-method coverage metrics: -- Method name and class -- Line coverage percentage -- Branch coverage percentage -- Lines covered / total -- Branches covered / total -- Complexity (if available) - -When multiple files are provided, line hits are merged across files so a line is counted -as covered if any test project covered it. - -Filters by coverage status (uncovered, below threshold, or all). -Output is JSON for easy post-processing into tables, CSV, or other formats. - -.PARAMETER CoberturaPath -Path(s) to Cobertura coverage.cobertura.xml file(s). Accepts multiple paths for multi-test-project merging. - -.PARAMETER CoverageThreshold -Minimum acceptable line coverage percentage. Methods below this threshold are flagged (default: 80). - -.PARAMETER BranchThreshold -Minimum acceptable branch coverage percentage for methods that contain branches (default: 70). - -.PARAMETER Filter -Which methods to include: - 'uncovered' - methods with 0% coverage only - 'below-threshold' - methods with line coverage < CoverageThreshold OR branch coverage < BranchThreshold (for methods with branches) - 'all' - all methods (default) - -.EXAMPLE -PS> & .\Extract-MethodCoverage.ps1 -CoberturaPath "coverage.cobertura.xml" -CoverageThreshold 80 -BranchThreshold 70 -Filter uncovered -Outputs a JSON array of uncovered methods. - -.EXAMPLE -PS> & .\Extract-MethodCoverage.ps1 -CoberturaPath @("tests1/coverage.cobertura.xml","tests2/coverage.cobertura.xml") -Merges coverage from multiple test projects and outputs combined method-level metrics. - -.OUTPUTS -Writes JSON array to stdout. -Sets exit code 0 on success, 2 on missing/invalid file. -#> - -foreach ($p in $CoberturaPath) { - if (-not (Test-Path $p)) { - Write-Error "Cobertura file not found: $p" - exit 2 - } -} - -# Merge methods across all Cobertura files using a stable key (Class|Method|Signature|File). -# Line hits and branch data are accumulated so coverage reflects all test projects. -$methodMap = @{} - -foreach ($p in $CoberturaPath) { - try { - [xml]$xml = Get-Content $p -Encoding UTF8 -ErrorAction Stop - } catch { - Write-Error "Failed to parse Cobertura XML: $_" - exit 2 - } - - foreach ($package in $xml.coverage.packages.package) { - foreach ($class in $package.classes.class) { - $className = $class.name - $classFilename = $class.filename - - foreach ($method in $class.methods.method) { - $key = "$className|$($method.name)|$($method.signature)|$classFilename" - - if (-not $methodMap.ContainsKey($key)) { - $complexity = if ($null -ne $method.complexity) { [int]$method.complexity } else { 1 } - if ($complexity -lt 1) { $complexity = 1 } - $methodMap[$key] = @{ - Class = $className - Method = $method.name - Signature = $method.signature - File = $classFilename - Complexity = $complexity - LineHits = @{} - BranchData = @{} - } - } - - # Accumulate line hits across files - foreach ($line in $method.lines.line) { - $lineNo = $line.number - $hits = [int]$line.hits - if ($methodMap[$key].LineHits.ContainsKey($lineNo)) { - $methodMap[$key].LineHits[$lineNo] += $hits - } else { - $methodMap[$key].LineHits[$lineNo] = $hits - } - - # Accumulate branch data - if ($line.branch -eq 'true' -and $line.'condition-coverage') { - if ($line.'condition-coverage' -match '\((\d+)/(\d+)\)') { - $covered = [int]$Matches[1] - $total = [int]$Matches[2] - if ($methodMap[$key].BranchData.ContainsKey($lineNo)) { - # Merge branch coverage across files by accumulating covered branches (capped at total) - $existingCovered = $methodMap[$key].BranchData[$lineNo].Covered - $existingTotal = $methodMap[$key].BranchData[$lineNo].Total - if ($existingTotal -ne $total) { - Write-Warning ("Branch total mismatch for {0} at line {1}: {2} vs {3}" -f $key, $lineNo, $existingTotal, $total) - } - $mergedTotal = [Math]::Max($existingTotal, $total) - $mergedCovered = [Math]::Min($existingCovered + $covered, $mergedTotal) - $methodMap[$key].BranchData[$lineNo] = @{ Covered = $mergedCovered; Total = $mergedTotal } - } else { - $methodMap[$key].BranchData[$lineNo] = @{ Covered = $covered; Total = $total } - } - } - } - } - } - } - } -} - -$methods = [System.Collections.Generic.List[PSCustomObject]]::new() - -foreach ($entry in $methodMap.Values) { - $totalLines = $entry.LineHits.Count - $coveredLineCount = ($entry.LineHits.Values | Where-Object { $_ -gt 0 } | Measure-Object).Count - $lineCoveragePercent = if ($totalLines -gt 0) { [math]::Round(($coveredLineCount / $totalLines) * 100, 1) } else { 0 } - - $branchesTotal = 0 - $branchesCovered = 0 - foreach ($bd in $entry.BranchData.Values) { - $branchesCovered += $bd.Covered - $branchesTotal += $bd.Total - } - $branchCoveragePercent = if ($branchesTotal -gt 0) { [math]::Round(($branchesCovered / $branchesTotal) * 100, 1) } else { 0 } - - # Apply filter - if ($Filter -eq 'uncovered' -and $lineCoveragePercent -gt 0) { continue } - if ($Filter -eq 'below-threshold') { - $lineOk = $lineCoveragePercent -ge $CoverageThreshold - $branchOk = ($branchesTotal -eq 0) -or ($branchCoveragePercent -ge $BranchThreshold) - if ($lineOk -and $branchOk) { continue } - } - - $methods.Add([PSCustomObject]@{ - Class = $entry.Class - Method = $entry.Method - Signature = $entry.Signature - File = $entry.File - Complexity = $entry.Complexity - LineCoverage = $lineCoveragePercent - BranchCoverage = $branchCoveragePercent - CoveredLines = $coveredLineCount - TotalLines = $totalLines - UncoveredLines = ($totalLines - $coveredLineCount) - CoveredBranches = $branchesCovered - TotalBranches = $branchesTotal - }) -} -# Sort by uncovered lines descending, then by line coverage ascending -$sorted = $methods | Sort-Object -Property @{Expression='UncoveredLines';Descending=$true}, @{Expression='LineCoverage';Descending=$false}, Class, Method - -# Output as JSON (empty array guard for zero results) -if ($sorted.Count -eq 0) { - Write-Output "[]" -} else { - $json = @($sorted) | ConvertTo-Json - Write-Output $json -} - -# Summary -Write-Host "METHODS_FILTERED:$($methods.Count)" -ForegroundColor Green -$uncovered = $methods | Where-Object { $_.LineCoverage -eq 0 } | Measure-Object | Select-Object -ExpandProperty Count -Write-Host "UNCOVERED_METHODS:$uncovered" -ForegroundColor $(if ($uncovered -gt 0) { 'Yellow' } else { 'Green' }) -exit 0 diff --git a/.agents/skills/detect-static-dependencies/SKILL.md b/.agents/skills/detect-static-dependencies/SKILL.md deleted file mode 100644 index 46bda03..0000000 --- a/.agents/skills/detect-static-dependencies/SKILL.md +++ /dev/null @@ -1,149 +0,0 @@ ---- -name: detect-static-dependencies -description: > - Scan C# source files for hard-to-test static dependencies — DateTime.Now/UtcNow, - File.*, Directory.*, Environment.*, HttpClient, Console.*, Process.*, and other - untestable statics. Produces a ranked report of static call sites by frequency. - USE FOR: find untestable statics, scan for static dependencies, testability audit, - identify hard-to-mock code, find DateTime.Now usage, detect static coupling, - testability report, static analysis for testability. - DO NOT USE FOR: generating wrappers (use generate-testability-wrappers), - migrating code (use migrate-static-to-wrapper), general code review, - or finding statics that are already behind abstractions. -license: MIT ---- - -# Detect Static Dependencies - -Scan a C# codebase for calls to hard-to-test static APIs and produce a ranked report showing which statics appear most frequently, which files are most affected, and which abstractions already exist in the .NET ecosystem to replace them. - -## When to Use - -- Auditing a project's testability before adding unit tests -- Understanding the scope of static coupling in a legacy codebase -- Prioritizing which statics to wrap first (highest-frequency wins) -- Creating a migration plan for incremental testability improvements - -## Response Guidelines - -- Scale the response to the user's request. A question about a specific category (e.g., "find time statics") should focus on that category with file locations and counts, not produce a full report across all categories. -- When the user provides a specific file or directory path, scan only that scope — do not expand to the entire solution unless asked. -- The full structured report format in Step 4 is for comprehensive audit requests. For focused questions, return only the relevant subset (e.g., category summary + affected files for the requested category). - -## When Not to Use - -- The user wants wrappers generated (hand off to `generate-testability-wrappers`) -- The user wants mechanical migration done (hand off to `migrate-static-to-wrapper`) -- The statics are already behind interfaces or `TimeProvider` -- The code is not C# / .NET - -## Inputs - -| Input | Required | Description | -|-------|----------|-------------| -| Target path | Yes | A file, directory, project (.csproj), or solution (.sln) to scan | -| Exclusion patterns | No | Glob patterns to skip (e.g., `**/obj/**`, `**/Migrations/**`) | -| Category filter | No | Limit to specific categories: `time`, `filesystem`, `environment`, `network`, `console`, `process` | - -## Workflow - -### Step 1: Determine scan scope - -Resolve the target to a set of `.cs` files: -- If a `.cs` file, scan that single file. -- If a directory, scan all `.cs` files recursively (excluding `obj/`, `bin/`). -- If a `.csproj`, find its directory and scan `.cs` files within. -- If a `.sln`, parse it, find all project directories, and scan `.cs` files across all projects. - -Always exclude `obj/`, `bin/`, and any user-specified exclusion patterns. - -### Step 2: Search for static dependency patterns - -Scan each file for calls matching these categories: - -| Category | Patterns to search for | Recommended replacement | -|----------|----------------------|------------------------| -| **Time** | `DateTime.Now`, `DateTime.UtcNow`, `DateTime.Today`, `DateTimeOffset.Now`, `DateTimeOffset.UtcNow`, `Task.Delay(`, `new CancellationTokenSource(TimeSpan` | `TimeProvider` (.NET 8+) | -| **File System** | `File.ReadAllText(`, `File.WriteAllText(`, `File.Exists(`, `File.Delete(`, `File.Copy(`, `File.Move(`, `Directory.Exists(`, `Directory.CreateDirectory(`, `Directory.GetFiles(`, `Directory.Delete(`, `Path.Combine(`, `Path.GetTempPath(` | `IFileSystem` (System.IO.Abstractions NuGet) | -| **Environment** | `Environment.GetEnvironmentVariable(`, `Environment.SetEnvironmentVariable(`, `Environment.MachineName`, `Environment.UserName`, `Environment.CurrentDirectory`, `Environment.Exit(` | Custom `IEnvironmentProvider` | -| **Network** | `new HttpClient(`, `HttpClient.GetAsync(`, `HttpClient.PostAsync(`, `HttpClient.SendAsync(` | `IHttpClientFactory` (built-in) | -| **Console** | `Console.WriteLine(`, `Console.ReadLine(`, `Console.Write(`, `Console.ReadKey(` | `IConsole` wrapper or `ILogger` | -| **Process** | `Process.Start(`, `Process.GetCurrentProcess(`, `Process.GetProcessesByName(` | Custom `IProcessRunner` | - -### Step 3: Aggregate and rank results - -Count each static call pattern across the entire scan scope. Produce a summary with: - -1. **Category summary** — total call sites per category (time, filesystem, env, etc.) -2. **Top patterns** — the 10 most frequent individual patterns ranked by count -3. **Most affected files** — files with the highest number of static dependencies -4. **Existing abstractions available** — for each category, note the recommended .NET abstraction: - - Time → `TimeProvider` (built-in since .NET 8) - - File system → `System.IO.Abstractions` (NuGet package) - - HTTP → `IHttpClientFactory` (built-in) - - Environment → custom `IEnvironmentProvider` - - Console → custom `IConsole` or `ILogger` - - Process → custom `IProcessRunner` - -### Step 4: Present the report - -Format the output as a structured report: - -``` -## Static Dependency Report - -**Scope**: -**Files scanned**: -**Total static call sites**: - -### Category Summary -| Category | Call Sites | Recommended Abstraction | -|-------------|-----------|------------------------| -| Time | 42 | TimeProvider (.NET 8+) | -| File System | 31 | System.IO.Abstractions | -| Environment | 12 | IEnvironmentProvider | -| ... | ... | ... | - -### Top 10 Patterns -| # | Pattern | Count | Files | -|---|---------------------|-------|-------| -| 1 | DateTime.UtcNow | 28 | 14 | -| 2 | File.ReadAllText | 18 | 9 | -| ... | - -### Most Affected Files -| File | Static Calls | Categories | -|-------------------------------|-------------|---------------------| -| Services/OrderProcessor.cs | 12 | Time, FileSystem | -| ... | - -### Migration Priority -1. **Time** (42 sites) — Use `TimeProvider`, zero NuGet dependencies on .NET 8+ -2. **File System** (31 sites) — Use `System.IO.Abstractions` NuGet package -3. ... -``` - -### Step 5: Suggest next steps - -Based on the report, recommend: -- Which category to tackle first (fewest dependencies, best built-in support) -- Whether to use `generate-testability-wrappers` for custom wrapper generation -- Whether to use `migrate-static-to-wrapper` for mechanical bulk migration - -## Validation - -- [ ] All `.cs` files in scope were scanned (check count) -- [ ] Report includes category totals, top patterns, and affected files -- [ ] Each detected pattern has a recommended replacement listed -- [ ] `obj/` and `bin/` directories were excluded -- [ ] Migration priority is ordered by impact (count × ease of replacement) - -## Common Pitfalls - -| Pitfall | Solution | -|---------|----------| -| Scanning `obj/` or generated code | Always exclude `obj/`, `bin/`, and `*.Designer.cs` | -| Counting wrapped calls as statics | Check if the call is behind an interface or injected service before counting | -| Missing statics inside lambdas/LINQ | Search covers all code within `.cs` files, including lambdas | -| Recommending `TimeProvider` on < .NET 8 | Check `TargetFramework` in `.csproj` — if < net8.0, recommend `NodaTime.IClock` or custom `ISystemClock` | -| Ignoring test projects | Only scan production code — exclude `*.Tests.csproj` projects from the scan | diff --git a/.agents/skills/dotnet-aot-compat/SKILL.md b/.agents/skills/dotnet-aot-compat/SKILL.md deleted file mode 100644 index bcfeca1..0000000 --- a/.agents/skills/dotnet-aot-compat/SKILL.md +++ /dev/null @@ -1,269 +0,0 @@ ---- -name: dotnet-aot-compat -description: > - Make .NET projects compatible with Native AOT and trimming by systematically - resolving IL trim/AOT analyzer warnings. USE FOR: making projects AOT-compatible, - fixing trimming warnings, resolving IL warnings (IL2026, IL2070, IL2067, IL2072, - IL3050), adding DynamicallyAccessedMembers annotations, enabling IsAotCompatible. - DO NOT USE FOR: publishing native AOT binaries, optimizing binary size, replacing - reflection-heavy libraries with alternatives. - INVOKES: no tools — pure knowledge skill. -license: MIT ---- - -# dotnet-aot-compat - -Make .NET projects compatible with Native AOT and trimming by systematically resolving all IL trim/AOT analyzer warnings. - -## When to Use This Skill - -- **"Make this project AOT-compatible"** -- **"Fix trimming warnings"** or **"fix IL warnings"** -- **"Resolve IL2070 / IL2067 / IL2072 / IL2026 / IL3050 warnings"** -- **"Add DynamicallyAccessedMembers annotations"** -- **"Enable IsAotCompatible in my .csproj"** -- **"My project has trim analyzer warnings after upgrading to net8.0"** -- **"Annotate reflection code for the trimmer"** - -## When Not to Use This Skill - -Do not use this skill when the project exclusively targets .NET Framework (net4x), which does not support the trim/AOT analyzers. - -## Prerequisites - -An existing .NET project targeting net8.0 or later (or multi-targeting with at least one net8.0+ TFM) and the corresponding .NET SDK installed. - -## Background: What AOT Compatibility Means - -Native AOT and the IL trimmer perform static analysis to determine what code is reachable. Reflection can break this analysis because the trimmer can't see what types/members are accessed at runtime. The `IsAotCompatible` property enables analyzers that flag these issues as build warnings (ILXXXX codes). - -## Critical Rules - -### ❌ Never suppress warnings incorrectly - -- **NEVER** use `#pragma warning disable` for IL warnings. It hides warnings from the Roslyn analyzer at build time, but the IL linker and AOT compiler still see the issue. The code will fail at trim/publish time. -- **NEVER** use `[UnconditionalSuppressMessage]`. It tells both the analyzer AND the linker to ignore the warning, meaning the trimmer cannot verify safety. Raising an error at build time is always preferable to hiding the issue and having it silently break at runtime. - -### 💡 Preferred approaches - -- **Prefer** `[DynamicallyAccessedMembers]` annotations to flow type information through the call chain. -- **Prefer** refactoring to eliminate patterns that break annotation flow (e.g., boxing `Type` through `object[]`). -- **Use** `[RequiresUnreferencedCode]` / `[RequiresDynamicCode]` / `[RequiresAssemblyFiles]` to mark methods as fundamentally incompatible with trimming, propagating the requirement to callers. This surfaces the issue clearly rather than hiding it — callers must explicitly acknowledge the incompatibility. - -### Annotation flow is key - -The trimmer tracks `[DynamicallyAccessedMembers]` annotations through assignments, parameter passing, and return values. If this flow is broken (e.g., by boxing a `Type` into `object`, storing in an untyped collection, or casting through interfaces), the trimmer loses track and warns. The fix is to preserve the flow, not suppress the warning. - -## Step-by-Step Procedure - -> **Do not explore the codebase up-front.** The build warnings tell you exactly which files and lines need changes. Follow a tight loop: **build → pick a warning → open that file at that line → apply the fix recipe → rebuild**. Reading or analyzing source files beyond what a specific warning points you to is wasted effort and leads to timeouts. Let the compiler guide you. -> -> ❌ Do NOT run `find`, `ls`, or `grep` to understand the project structure before building. Do NOT read README, docs, or architecture files. Your first action should be Step 1 (enable AOT analysis), then build. - -### Step 1: Enable AOT analysis in the .csproj - -Add `IsAotCompatible`. If the project doesn't exclusively target net8.0+, add a TFM condition (AOT analysis requires net8.0+): - -```xml - - true - -``` - -This automatically sets `EnableTrimAnalyzer=true` and `EnableAotAnalyzer=true` for compatible TFMs. For multi-targeting projects (e.g., `netstandard2.0;net8.0`), the condition ensures no `NETSDK1210` warnings on older TFMs. - -### Step 2: Build and collect warnings - -```bash -dotnet build -f --no-incremental 2>&1 | grep 'IL[0-9]\{4\}' -``` - -Sort and deduplicate. Common warning codes: -- **IL2070**: Reflection call on a `Type` parameter missing `[DynamicallyAccessedMembers]` -- **IL2067**: Passing an unannotated `Type` to a method expecting `[DynamicallyAccessedMembers]` -- **IL2072**: Return value or extracted value missing annotation (often from unboxing) -- **IL2057**: `Type.GetType(string)` with a non-constant argument -- **IL2026**: Calling a method marked `[RequiresUnreferencedCode]` -- **IL2050**: P/invoke method with COM marshalling parameters -- **IL2075**: Return value flows into reflection without annotation -- **IL2091**: Generic argument missing `[DynamicallyAccessedMembers]` required by constraint -- **IL3000**: `Assembly.Location` returns empty string in single-file/AOT apps -- **IL3050**: Calling a method marked `[RequiresDynamicCode]` - -### Step 3: Triage warnings by code (do NOT read every file) - -Group the warnings from Step 2 by warning code and count them. **Do not open individual files yet.** Identify the top 1-2 patterns by count — these drive your fix strategy: - -| Pattern | Typical fix | -|---------|-------------| -| Many IL2026 + IL3050 from `JsonSerializer` | **Go to Strategy C immediately** — create a `JsonSerializerContext`, then batch-update all call sites | -| IL2070/IL2087 on `Type` parameters | Add `[DynamicallyAccessedMembers]` to the innermost method, then cascade outward | -| IL2067 passing unannotated `Type` | Annotate the parameter at the source | - -**In most real projects, IL2026/IL3050 from JsonSerializer dominate.** Start with Strategy C unless the warning breakdown clearly shows otherwise. After the batch JSON fix, handle remaining warnings with Strategies A–B. Only use Strategy D as a last resort. - -### Step 4: Fix warnings iteratively (innermost first) - -Work from the **innermost** reflection call outward. Each fix may cascade new warnings to callers. - -**Stay warning-driven.** For each warning, open only the file and line the compiler reported, identify the pattern, apply the matching fix recipe below, and move on. Do not scan the codebase for similar patterns or try to understand the full architecture — fix what the compiler tells you, rebuild, and let new warnings guide the next change. Fix a small batch of warnings (5-10), then rebuild immediately to check progress. - -**Use sub-agents when available.** If you can launch sub-agents (e.g., via a `task` tool), dispatch **multiple sub-agents in parallel** to edit different files simultaneously. Keep the main loop focused on building, parsing warnings, and dispatching — delegate actual file edits to sub-agents. For batch JSON updates, give each sub-agent 5-10 files to update in one prompt. **After 2 build-fix cycles, dispatch all remaining file edits to sub-agents in parallel — do not continue fixing files sequentially.** Example: - -> Update these files to use source-generated JSON: `src/Models/Resource.Serialization.cs`, `src/Models/Identity.Serialization.cs`, `src/Models/Plan.Serialization.cs`. In each file, replace `JsonSerializer.Serialize(writer, value)` with `JsonSerializer.Serialize(writer, value, MyProjectJsonContext.Default.TypeName)` and `JsonSerializer.Deserialize(ref reader)` with `JsonSerializer.Deserialize(ref reader, MyProjectJsonContext.Default.TypeName)`. Only edit the JsonSerializer call sites. - -#### Strategy A: Add `[DynamicallyAccessedMembers]` (preferred) - -When a method uses reflection on a `Type` parameter, annotate the parameter to tell the trimmer what members are needed: - -```csharp -using System.Diagnostics.CodeAnalysis; - -// Before (warns IL2070): -void Process(Type t) { - var method = t.GetMethod("Foo"); // trimmer can't verify -} - -// After (clean): -void Process([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] Type t) { - var method = t.GetMethod("Foo"); // trimmer preserves public methods -} -``` - -When you annotate a parameter, **all callers** must now pass properly annotated types. This cascades outward — follow each caller and annotate or refactor as needed. **The caller's annotation must include at least the same member types as the callee's.** If the callee requires `PublicConstructors | NonPublicConstructors`, the caller must specify the same or a superset — using only `NonPublicConstructors` will produce IL2091. - -#### Strategy B: Refactor to preserve annotation flow - -When annotation flow is broken by boxing (storing `Type` in `object`, `object[]`, or untyped collections), **refactor** to pass the `Type` directly: - -```csharp -// BROKEN: Type boxed into object[], annotation lost -void Process(object[] args) { - Type t = (Type)args[0]; // IL2072: annotation lost through boxing - Evaluate(t, ...); -} - -// FIXED: Pass Type as a separate, annotated parameter -void Process( - object[] args, - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] Type calleeType, - ...) { - Evaluate(calleeType, ...); // annotation flows cleanly -} -``` - -Common patterns that break flow and how to fix them: -- **`object[]` parameter bags**: Extract the `Type` into a dedicated annotated parameter -- **Dictionary/List storage**: Use a typed field with annotation instead -- **Interface indirection**: Add annotation to the interface method's parameter -- **Property with boxing getter**: Annotate the property's return type - -#### Strategy C: Source-generated JSON serialization (batch fix) - -When most warnings are IL2026/IL3050 from `JsonSerializer.Serialize`/`Deserialize`, this is a single mechanical fix applied in bulk: - -1. **Collect affected types** — grep for all `JsonSerializer.Serialize` and `JsonSerializer.Deserialize` call sites. Extract the type being serialized (the `` in `Deserialize`, or the runtime type of the object in `Serialize`). - -2. **Create one `JsonSerializerContext`** with `[JsonSerializable]` for every type found. **Skip types from external packages** (e.g., `ResponseError` from `Azure.Core`) — they won't source-generate for types you don't own. Handle external types separately via Gotcha #1 below. - -```csharp -[JsonSerializerContext] -[JsonSerializable(typeof(ManagedServiceIdentity))] -[JsonSerializable(typeof(SystemData))] -// ... one attribute per type YOU OWN -// Do NOT add types from external packages (e.g., ResponseError) -internal partial class MyProjectJsonContext : JsonSerializerContext { } -``` - -3. **Batch-update all call sites** — do not read each file individually. Apply the pattern mechanically: - - `JsonSerializer.Serialize(obj)` → `JsonSerializer.Serialize(obj, MyProjectJsonContext.Default.TypeName)` - - `JsonSerializer.Deserialize(json)` → `JsonSerializer.Deserialize(json, MyProjectJsonContext.Default.TypeName)` - - Find and update all call sites in one pass: - ```bash - # Find all files with JsonSerializer calls - grep -rl 'JsonSerializer\.\(Serialize\|Deserialize\)' src/ --include='*.cs' - ``` - Then use sequential `edit` calls to apply the same transformation to every matching file. **Do not use `sed` for C# code** — generics like `Deserialize()` have angle brackets and nested parentheses that sed will mangle. - -4. **Build once** to verify. Remaining warnings will be non-serialization issues — handle those with Strategies A–B or D. - -#### Strategy D: `[RequiresUnreferencedCode]` (last resort) - -When a method fundamentally requires arbitrary reflection that cannot be statically described: - -```csharp -[RequiresUnreferencedCode("Loads plugins by name using Assembly.Load")] -public void LoadPlugin(string assemblyName) { - var asm = Assembly.Load(assemblyName); - // ... -} -``` - -This propagates to callers — they must also be annotated with `[RequiresUnreferencedCode]`. Use sparingly; it marks the entire call chain as trim-incompatible. - -### Step 5: Rebuild and repeat - -After each small batch of fixes (5-10 warnings), rebuild with `--no-incremental` and check for new warnings. **Do not attempt to fix all warnings before rebuilding** — frequent rebuilds catch mistakes early and reveal cascading warnings. Fixes cascade — annotating an inner method may surface warnings in its callers. Repeat until `0 Warning(s)`. - -### Step 6: Validate all TFMs - -Build all target frameworks to ensure: -- **0 IL warnings** on net8.0+ TFMs -- **No NETSDK1210 warnings** (the `IsAotCompatible` condition handles this) -- **Clean builds** on older TFMs (netstandard2.0, net472, etc.) - -```bash -dotnet build # builds all TFMs -``` - -## Stop Signals - -- **Do not analyze more than 2-3 representative files per warning pattern.** After identifying the fix for a pattern, apply it to all matching files without reading each one first. -- **Start fixing after one build.** Do not do a second analysis pass — begin implementing fixes for the most common warning pattern immediately after Step 3 triage. -- Stop after achieving **0 IL warnings** for net8.0+ TFMs. Don't optimize or refactor already-clean annotations. -- If a warning requires **architectural refactoring** beyond annotation flow fixes (e.g., replacing an entire serialization layer), document it and stop — don't rewrite large subsystems. -- Limit to **3 build-fix iterations** per warning. If annotation flow doesn't resolve it after 3 attempts, escalate to `[RequiresUnreferencedCode]`. -- Don't chase warnings in **third-party dependencies** you can't modify. Note them and move on. -- If the user asked a scoped question (e.g., "fix warnings in this file"), don't expand to the entire project. - -## Polyfills for Older TFMs - -For multi-targeting projects that include netstandard2.0 or net472, you need polyfills for `DynamicallyAccessedMembersAttribute` and related types. See [references/polyfills.md](references/polyfills.md). - -## Common Gotchas - -1. **External types without AOT-safe serialization**: When a type comes from a dependency you can't modify (e.g., `ResponseError` from `Azure.Core`) and it lacks a source-generated serializer, `Options.GetConverter()` is reflection-based and will produce IL warnings. First check if the type implements `IJsonModel` (common in Azure SDK) — if so, bypass `JsonSerializer` entirely: - -```csharp -// Before (IL2026 — JsonSerializer uses reflection): -JsonSerializer.Serialize(writer, errorValue); - -// After (AOT-safe — uses IJsonModel directly): -((IJsonModel)errorValue).Write(writer, ModelReaderWriterOptions.Json); - -// For deserialization: -var error = ((IJsonModel)new ResponseError()).Create(ref reader, ModelReaderWriterOptions.Json); -``` - -Do **not** add the external type to your `JsonSerializerContext` — it won't source-generate for types you don't own. If the type doesn't implement `IJsonModel`, write a custom `JsonConverter` with manual `Utf8JsonReader`/`Utf8JsonWriter` logic and register it via `[JsonSourceGenerationOptions]` on your context. - -2. **Serialization libraries**: Most reflection-based serializers (e.g., `Newtonsoft.Json`, `XmlSerializer`) are not AOT-compatible. Migrate to a source-generation-based serializer such as `System.Text.Json` with a `JsonSerializerContext`. If migration is not feasible, mark the serialization call site with `[RequiresUnreferencedCode]`. - -3. **Shared projects / projitems**: When source is shared between multiple projects via ``, annotations added to shared code affect ALL consuming projects. Verify that all consumers still build cleanly. - -## References - -[Limitations](https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/?tabs=windows%2Cnet8#limitations-of-native-aot-deployment) -[Conceptual: Understanding trimming](https://learn.microsoft.com/en-us/dotnet/core/deploying/trimming/trimming-concepts) -[How-to: trim compat](https://learn.microsoft.com/en-us/dotnet/core/deploying/trimming/fixing-warnings) - -## Checklist - -- [ ] Added `` with TFM condition to .csproj -- [ ] Built with AOT analyzers enabled (net8.0+ TFM) -- [ ] Fixed all IL warnings via annotations or refactoring -- [ ] No `#pragma warning disable` or `[UnconditionalSuppressMessage]` used for any IL warning -- [ ] Polyfills present for older TFMs if needed -- [ ] All target frameworks build with 0 warnings -- [ ] Verified shared/linked source doesn't break sibling projects diff --git a/.agents/skills/dotnet-aot-compat/references/polyfills.md b/.agents/skills/dotnet-aot-compat/references/polyfills.md deleted file mode 100644 index a577f2e..0000000 --- a/.agents/skills/dotnet-aot-compat/references/polyfills.md +++ /dev/null @@ -1,43 +0,0 @@ -# Polyfills for Older TFMs - -`DynamicallyAccessedMembersAttribute` shipped in .NET 5. For projects targeting netstandard2.0 or net472, you need a polyfill. The trimmer recognizes the attribute by name, so a local copy works: - -```csharp -#if !NET -namespace System.Diagnostics.CodeAnalysis -{ - [AttributeUsage(AttributeTargets.Field | AttributeTargets.ReturnValue | - AttributeTargets.GenericParameter | AttributeTargets.Parameter | - AttributeTargets.Property, Inherited = false)] - internal sealed class DynamicallyAccessedMembersAttribute : Attribute - { - public DynamicallyAccessedMembersAttribute(DynamicallyAccessedMemberTypes memberTypes) - => MemberTypes = memberTypes; - public DynamicallyAccessedMemberTypes MemberTypes { get; } - } - - [Flags] - internal enum DynamicallyAccessedMemberTypes - { - None = 0, - PublicParameterlessConstructor = 0x0001, - PublicConstructors = 0x0002 | PublicParameterlessConstructor, - NonPublicConstructors = 0x0004, - PublicMethods = 0x0008, - NonPublicMethods = 0x0010, - PublicFields = 0x0020, - NonPublicFields = 0x0040, - PublicNestedTypes = 0x0080, - NonPublicNestedTypes = 0x0100, - PublicProperties = 0x0200, - NonPublicProperties = 0x0400, - PublicEvents = 0x0800, - NonPublicEvents = 0x1000, - Interfaces = 0x2000, - All = ~None // Discouraged — prefer specific flags - } -} -#endif -``` - -Similarly for `RequiresUnreferencedCodeAttribute` and `UnconditionalSuppressMessageAttribute` if needed on older TFMs. diff --git a/.agents/skills/migrate-nullable-references/SKILL.md b/.agents/skills/migrate-nullable-references/SKILL.md deleted file mode 100644 index e7e77af..0000000 --- a/.agents/skills/migrate-nullable-references/SKILL.md +++ /dev/null @@ -1,291 +0,0 @@ ---- -name: migrate-nullable-references -description: > - Enable nullable reference types in a C# project and systematically resolve all warnings. - USE FOR: adopting NRTs in existing codebases, file-by-file or project-wide migration, - fixing CS8602/CS8618/CS86xx warnings, annotating APIs for nullability, cleaning up - null-forgiving operators, upgrading dependencies with new nullable annotations. - DO NOT USE FOR: projects already fully migrated with zero warnings (unless auditing - suppressions), fixing a handful of nullable warnings in code that already has NRTs enabled, - suppressing warnings without fixing them, C# 7.3 or earlier projects. - INVOKES: Get-NullableReadiness.ps1 scanner script. -license: MIT ---- - -# Nullable Reference Migration - -Enable C# nullable reference types (NRTs) in an existing codebase and systematically resolve all warnings. The outcome is a project (or solution) with `enable`, zero nullable warnings, and accurately annotated public API surfaces — giving both the compiler and consumers reliable nullability information. - -## When to Use - -- Enabling nullable reference types in an existing C# project or solution -- Systematically resolving CS86xx nullable warnings after enabling the feature -- Annotating a library's public API surface so consumers get accurate nullability information -- Upgrading a dependency that has added nullable annotations and new warnings appear -- Analyzing suppressions in a code base that has already enabled NRTs to determine whether they can be removed - -## When Not to Use - -- The project already has `enable` and zero warnings — the migration is done unless the user wants to re-examine suppressions with a view to removing unnecessary ones (see Step 6) -- The user only wants to suppress warnings without fixing them (recommend against this) -- The code targets C# 7.3 or earlier, which does not support nullable reference types - -## Inputs - -| Input | Required | Description | -|-------|----------|-------------| -| Project or solution path | Yes | The `.csproj`, `.sln`, or build entry point to migrate | -| Migration scope | No | `project-wide` (default) or `file-by-file` — controls the rollout strategy | -| Build command | No | How to build the project (e.g., `dotnet build`, `msbuild`, or a repo-specific build script). Detect from the repo if not provided | -| Test command | No | How to run tests (e.g., `dotnet test`, or a repo-specific test script). Detect from the repo if not provided | - -## Workflow - -> 🛑 **Zero runtime behavior changes.** NRT migration is strictly a metadata and annotation exercise. The generated IL must not change — no new branches, no new null checks, no changed control flow, no added or removed method calls. The only acceptable changes are nullable annotations (`?`), nullable attributes (`[NotNullWhen]`, etc.), `!` operators (metadata-only), and `#nullable` directives. If you discover a missing runtime null guard or a latent bug during migration, **do not fix it inline**. Instead, offer to insert a `// TODO: Consider adding ArgumentNullException.ThrowIfNull(param)` comment at the site so the user can address it as a separate change. Never mix behavioral fixes into an annotation commit. - -> **Commit strategy:** Commit at each logical boundary — after enabling `` (Step 2), after fixing dereference warnings (Step 3), after annotating declarations (Step 4), after applying nullable attributes (Step 5), and after cleaning up suppressions (Step 6). This keeps each commit focused and reviewable, and prevents losing work if a later step reveals a design issue that requires rethinking. For file-by-file migrations, commit each file or batch of related files individually. - -### Step 1: Evaluate readiness - -> **Optional:** Run `scripts/Get-NullableReadiness.ps1 -Path ` to automate the checks below. The script reports ``, ``, ``, `` settings and counts `#nullable disable` directives, `!` operators, and `#pragma warning disable CS86xx` suppressions. Use `-Json` for machine-readable output. - -1. Identify how the project is built and tested. Look for build scripts (e.g., `build.cmd`, `build.sh`, `Makefile`), a `.sln` file, or individual `.csproj` files. If the repo uses a custom build script, use it instead of `dotnet build` throughout this workflow. -2. Run `dotnet --version` to confirm the SDK is installed. Nullable reference types (NRTs) require C# 8.0+ (`.NET Core 3.0` / `.NET Standard 2.1` or later). -3. Open the `.csproj` (or `Directory.Build.props` if properties are set at the repo level) and check the `` and ``. If the project multi-targets, note all TFMs. - -> **Stop if the language version or target framework is insufficient.** If `` is below 8.0, or the project targets a framework that defaults to C# 7.x (e.g., `.NET Framework 4.x` without an explicit ``), NRTs cannot be enabled as-is. Inform the user explicitly: explain what needs to change (set `8.0` or higher, or retarget to `.NET Core 3.0+` / `.NET 5+`), and ask whether they want to make that update and continue, or abort the migration. Do not silently proceed or assume the update is acceptable. -4. Check whether `` is already set. If it is set to `enable`, skip to Step 5 to audit remaining warnings. -5. Determine the project type — this shapes annotation priorities throughout the migration: - - **Library**: Focus on public API contracts first. Every `?` on a public parameter or return type is a contract change that consumers depend on. Be precise and conservative. - - **Application (web, console, desktop)**: Focus on null safety at boundaries — deserialization, database queries, user input, external API responses. Internal plumbing can be annotated more liberally. - - **Test project**: Lower priority for annotation precision. Use `!` more freely on test setup and assertions where null is never expected. Focus on ensuring test code compiles cleanly. - -### Step 2: Choose a rollout strategy - -Pick one of the following strategies based on codebase size and activity level. Recommend the strategy to the user and confirm before proceeding. - -> **Multi-project solutions:** Migrate in dependency order — shared libraries and core projects first, then projects that consume them. Annotating a dependency first eliminates cascading warnings in its consumers and prevents doing work twice. - -Regardless of strategy, **start at the center and work outward**:begin with core domain models, DTOs, and shared utility types that have few dependencies but are used widely. Annotating these first eliminates cascading warnings across the codebase and gives the biggest return on effort. Then move on to higher-level services, controllers, and UI code that depend on the core types. This approach minimizes the number of warnings at each step and prevents getting overwhelmed by a flood of warnings from a large project-wide enable. Prefer to create at least one PR per project, or per layer, to keep changesets reviewable and focused. If there are relatively few annotations needed, a single project-wide enable and single PR may be appropriate. - -#### Strategy A — Project-wide enable (small to medium projects) - -Best when the project has fewer than roughly 50 source files or the team wants to finish in one pass. - -1. Add `enable` to the `` in the `.csproj`. -2. Build and address all warnings at once. - -#### Strategy B — Warnings-first, then annotations (large or active projects) - -Best when the codebase is large or under active development by multiple contributors. - -1. Add `warnings` to the `.csproj`. This enables warnings without changing type semantics. -2. Build, fix all warnings from Step 3 onward. -3. Change to `enable` to activate annotations — this triggers a second wave of warnings. -4. Resolve the annotation-phase warnings from Step 4 onward. - -#### Strategy C — File-by-file (very large projects) - -Best for large legacy codebases where enabling project-wide would produce an unmanageable number of warnings. - -1. Set `disable` (or omit it) at the project level. -2. Add `#nullable enable` at the top of each file as it is migrated. -3. Prioritize files in dependency order: shared utilities and models first, then higher-level consumers. - -> **Build checkpoint:** After enabling `` (or adding `#nullable enable` to the first batch of files), do a **clean build** (e.g., `dotnet build --no-incremental`, or delete `bin`/`obj` first). Incremental builds only recompile changed files and will hide warnings in untouched files. Record the initial warning count — this is the baseline to work down from. Do not proceed to fixing warnings without first confirming the project still compiles. Use clean builds for all subsequent build checkpoints in this workflow. - -### Step 3: Fix dereference warnings - -> **Prioritization:** Work through files in dependency order — start with core models and shared utilities that other code depends on, then move to higher-level consumers. Within each file, fix public and protected members first (these define the contract), then internal and private members. This order minimizes cascading warnings: fixing a core type's annotations often resolves warnings in its consumers automatically. - -Build the project and work through dereference warnings. These are the most common: - -| Warning | Meaning | Typical fix | -|---------|---------|-------------| -| CS8602 | Dereference of a possibly null reference | Prefer annotation-only fixes: make the upstream type nullable (`T?`) if null is valid, or use `!` if you can verify the value is never null at this point. Adding a null check or `?.` changes runtime behavior — reserve those for a separate commit (see zero-behavior-change rule above) | -| CS8600 | Converting possible null to non-nullable type | Add `?` to the target type if null is valid, or use `!` if you can verify the value is never null. Adding a null guard changes runtime behavior | -| CS8603 | Possible null reference return | Change the return type to nullable (`T?`) if the method can genuinely return null. **Do not suppress with `!` if the method can genuinely return null** — fix the return type instead. This is the single most important rule in NRT migration: a non-nullable return type is a promise to every caller that null will never be returned | -| CS8604 | Possible null reference argument | Mark the parameter as nullable if null is valid, or use `!` if the argument is verifiably non-null. Adding a null check before passing changes runtime behavior | - -> ❌ **Do not use `?.` as a quick fix for dereference warnings.** Replacing `obj.Method()` with `obj?.Method()` silently changes runtime behavior — the call is skipped instead of throwing. Only use `?.` when you intentionally want to tolerate null. - -> ❌ **Do not sprinkle `!` to silence warnings.** Each `!` is a claim that the value is never null. If that claim is wrong, you have hidden a `NullReferenceException`. Add a null check or make the type nullable instead. - -> ❌ **Never use `return null!` to keep a return type non-nullable.** If a method returns `null`, the return type must be `T?`. Writing `return null!` hides a null behind a non-nullable signature — callers trust the signature, skip null checks, and get `NullReferenceException` at runtime. This applies to `null!`, `default!`, and any cast that makes the compiler accept null in a non-nullable position. The only acceptable use of `!` on a return value is when the value is **provably never null** but the compiler cannot see why. - -> ⚠️ **Do not add `?` to value types unless you intend to change the runtime type.** For reference types, `?` is metadata-only. For value types (`int`, enums, structs), `?` changes the type to `Nullable`, altering the method signature, binary layout, and boxing behavior. - -**Decision flowchart for each warning:** - -1. **Is null a valid value here by design?** - - **Yes** → add `?` to the declaration (make it nullable). - - **No** → go to step 2. - - **Unsure** → ask the user before proceeding. -2. **Can you prove the value is never null at this point?** - - **Yes, with a code path the compiler can't see** → add `!` with a comment explaining why. - - **Yes, by adding a guard** → add a null check (`if`, `??`, `is not null`). - - **No** → the type should be nullable (go back to step 1 — the answer is "Yes"). - -Guidance: - -- Prefer explicit null checks (`if`, `is not null`, `??`) over the null-forgiving operator (`!`). -- Use the null-forgiving operator only when you can prove the value is never null but the compiler cannot, and add a comment explaining why. -- Guard clause libraries (e.g., Ardalis.GuardClauses, Dawn.Guard) often decorate parameters with `[NotNull]`, which narrows null state after the guard call. After `Guard.Against.NullOrEmpty(value, nameof(value))`, the compiler already narrows `string?` to `string` — do not add a redundant `!` at the subsequent assignment. Check whether the guard method uses `[NotNull]` before assuming the compiler needs help. -- When a method legitimately returns null, change the return type to `T?` — do not hide nulls behind a non-nullable signature. -- `Debug.Assert(x != null)` acts as a null-state hint to the compiler just like an `if` check. Use it at the top of a method or block to inform the flow analyzer about invariants and eliminate subsequent `!` operators in that scope. Note: `Debug.Assert` informs the compiler but is stripped from Release builds — it does not protect against null at runtime. For public API boundaries, prefer an explicit null check or `ArgumentNullException`. -- If you find yourself adding `!` at every call site of an internal method, consider making that parameter nullable instead. Reserve `!` for cases where the compiler genuinely cannot prove non-nullness. -- When a boolean-returning helper method's result guarantees a nullable parameter is non-null (e.g., `if (IsValid(x))` implies `x != null`), prefer adding `[NotNullWhen(true)]` to the helper's parameter over using `!` at every call site. This is a metadata-only change (no behavior change) that eliminates `!` operators downstream while giving the compiler real flow information. -- For fields that are always set after construction (e.g., by a framework, an `Init()` method, or a builder pattern), prefer `= null!` on the field declaration over adding `!` at every use site. A field accessed 50 times should have one `= null!`, not fifty `field!` assertions. This keeps the field non-nullable in the type system while acknowledging the late initialization. Pair with `[MemberNotNull]` on the initializing method when possible. -- For generic methods returning `default` on an unconstrained type parameter (e.g., `FirstOrDefault`), use `[return: MaybeNull] T` rather than `T?`. Writing `T?` on an unconstrained generic changes value-type signatures to `Nullable`, altering the method signature and binary layout. `[return: MaybeNull]` preserves the original signature while communicating that the return may be null for reference types. -- LINQ's `Where(x => x != null)` does not narrow `T?` to `T` — the compiler cannot track nullability through lambdas passed to generic methods. Use `source.OfType()` to filter nulls with correct type narrowing. - -> **Build checkpoint:** After fixing dereference warnings, build and confirm zero CS8602/CS8600/CS8603/CS8604 warnings remain before moving to annotation warnings. - -### Step 4: Annotate declarations - -Start by deciding the **intended nullability** of each member based on its design purpose — should this parameter accept null? Can this return value ever be null? Annotate accordingly, then address any resulting warnings. Do not let warnings drive your annotations; that leads to over-annotating with `?` or scattering `!` to silence the compiler. - -> **When to ask the user:** Do not guess API contracts. Never infer nullability intent from usage frequency or naming conventions alone — if intent is not explicit in code or documentation, ask the user. Specifically, ask before: (1) changing a public method's return type to nullable or adding `?` to a public parameter — this changes the API contract consumers depend on; (2) deciding whether a property should be nullable vs. required when the design intent is unclear; (3) choosing between a null check and `!` when you cannot determine from context whether null is a valid state. For internal/private members where the answer is obvious from usage, proceed without asking. - -> ❌ **Do not let warnings drive annotations.** Decide the intended nullability of each member first, then annotate. Adding `?` everywhere to make warnings disappear defeats the purpose — callers must then add unnecessary null checks. Adding `!` everywhere hides bugs. - -> ⚠️ **Return types must reflect semantic nullability, not just compiler satisfaction.** A common mistake is removing `?` from a return type because the implementation uses `default!` or a cast that satisfies the compiler. If the method can return null by design, its return type must be nullable — regardless of whether the compiler warns. Key patterns: -> - Methods named `*OrDefault` (`FirstOrDefault`, `SingleOrDefault`, `FindOrDefault`) → return type must be nullable (`T?`, `object?`, `dynamic?`) because "or default" means "or null" for reference types. -> - `ExecuteScalar` and similar database methods → return type must be `object?` because the result can be `DBNull.Value` or null when no rows match. -> - `Find`, `TryGet*` (out parameter), and lookup methods → return type should be nullable when the item may not exist. -> - Any method documented or designed to return null on failure, not-found, or empty-input → nullable return type. -> -> The compiler cannot catch a *missing* `?` on a return type when the implementation hides null behind `!` or `default!`. This makes the annotation wrong for consumers — they trust the non-nullable signature and skip null checks, leading to `NullReferenceException` at runtime. - -> ⚠️ **Do not remove existing `ArgumentNullException` checks.** A non-nullable parameter annotation is a compile-time hint only — it does not prevent null at runtime. Callers using older C# versions, other .NET languages, reflection, or `!` can still pass null. - -> ⚠️ **Flag public API methods missing runtime null validation — but do not add checks.** While annotating, check each `public` and `protected` method: if a parameter is non-nullable (`T`, not `T?`), there should be a runtime null check (e.g., `ArgumentNullException.ThrowIfNull(param)` or `if (param is null) throw new ArgumentNullException(...)`). Without one, a null passed at runtime causes a `NullReferenceException` deep in the method body instead of a clear `ArgumentNullException` at the entry point. Adding a null guard is a runtime behavior change and must not be part of the NRT migration. Instead, ask the user whether they want a `// TODO: Consider adding ArgumentNullException.ThrowIfNull(param)` comment inserted at the site. This is especially important for libraries where callers may not have NRTs enabled. - -> **Methods with defined behavior for null should accept nullable parameters.** If a method handles null input gracefully — returning null, returning a default, or returning a failure result instead of throwing — the parameter should be `T?`, not `T`. The BCL follows this convention: `Path.GetPathRoot(string?)` returns null for null input, while `Path.GetFullPath(string)` throws. Only use a non-nullable parameter when null causes an exception. Marking a parameter as non-nullable when the method actually tolerates null forces callers to add unnecessary null checks before calling. -> -> **Gray areas:** When a parameter is neither validated, sanitized, nor documented for null, consider: (1) Is null ever passed in your own codebase? If yes → nullable. (2) Is null likely used as a "default" or no-op placeholder by callers? If yes → nullable. (3) Do similar methods in the same area accept null? If yes → nullable for consistency. (4) If the method is largely oblivious to null and just happens to work, but null makes no semantic sense for the API's purpose → non-nullable. When in doubt between nullable and non-nullable for a parameter, prefer nullable — it is safer and can be tightened later. - -After dereference warnings are resolved, address annotation warnings: - -| Warning | Meaning | Typical fix | -|---------|---------|-------------| -| CS8618 | Non-nullable field/property not initialized in constructor | Initialize the member, make it nullable (`?`), or use `required` (C# 11+). For fields that are always set after construction but outside the constructor (e.g., by a framework lifecycle method, an `Init()` call, or a builder pattern), use `= null!` to declare intent while keeping the field non-nullable at every use site. If a helper method initializes fields, decorate it with `[MemberNotNull(nameof(field))]` so the compiler knows the field is non-null after the call | -| CS8625 | Cannot convert null literal to non-nullable type | Make the target nullable or provide a non-null value | -| CS8601 | Possible null reference assignment | Same techniques as CS8600 | - -For each type, decide: **should this member ever be null?** - -- **Yes** → add `?` to its declaration. -- **No** → ensure it is initialized in every constructor path, or mark it `required` (C# 11+). -- **No, but it is set after the constructor** (e.g., by a framework method, a builder, or a two-phase init pattern) → use `= null!` on the field declaration. This keeps the field's type non-nullable everywhere it is used, while telling the compiler "I guarantee this will be set before access." This is far preferable to adding `!` at every use site — a field accessed 50 times would need 50 `!` operators instead of one `= null!`. If the initialization is done by a specific method, also consider `[MemberNotNull(nameof(field))]` on that method. - -Focus annotation effort on public and protected APIs first — these define the contract that consumers depend on. Internal and private code can tolerate `!` more liberally since it does not affect external callers. - -> **Public libraries: track breaking changes.** If the project is a library consumed by others, create a `nullable-breaking-changes.md` file (or equivalent) and record every public API change that could affect consumers. While adding `?` to a reference type is metadata-only and not binary-breaking, it IS source-breaking for consumers who have NRTs enabled — they will get new warnings or errors. Key changes to document: -> - Return types changed from `T` to `T?` (consumers must now handle null) -> - Parameters changed from `T?` to `T` (consumers can no longer pass null) -> - Parameters changed from `T` to `T?` (existing null checks in callers become unnecessary — low impact but worth noting) -> - `?` added to a value type parameter or return (changes `T` to `Nullable` — binary-breaking) -> - New `ArgumentNullException` guards added where none existed -> - Any behavioral changes discovered and fixed during annotation (e.g., a method that silently accepted null now throws) -> -> Present this file to the user for review. It may also serve as the basis for release notes. - -Pay special attention to: - -- **DTOs vs domain models**: Apply different nullability strategies depending on the role of the class. **DTOs and serialization models** cross trust boundaries (JSON, forms, external APIs) — their properties should be nullable by default unless enforced by the serializer, because deserialized data can always be null regardless of the declared type. Use `required` (C# 11+), `[JsonRequired]` (.NET 7+), or runtime validation to enforce non-null constraints. **Domain models** represent internal invariants — prefer non-nullable properties with constructor enforcement, making invalid state unrepresentable. This distinction is where migrations most often go wrong: treating a DTO as a domain model leads to runtime `NullReferenceException`; treating a domain model as a DTO leads to unnecessary null checks everywhere. -- **Event handlers and delegates**: The pattern `EventHandler? handler = SomeEvent; handler?.Invoke(...)` is idiomatic. -- **Struct reference-type fields**: Reference-type fields in structs are null when using `default(T)`. If `default` is valid usage for the struct, those fields must be nullable. If `default` is never expected (the struct is only created by specific APIs), keep them non-nullable to avoid burdening every consumer with unnecessary null checks. -- **Post-Dispose state**: If a field or property is non-null for the entire useful lifetime of the object but may become null after `Dispose`, keep it non-nullable. Using an object after disposal is a contract violation — do not weaken annotations for that case. -- **Overrides and interface implementations**: An override can return a stricter (non-nullable) type than the base method declares. If your implementation never returns null but the base/interface returns `T?`, you can declare the override as returning `T`. Parameter types must match the base exactly. -- **Widely-overridden virtual return types**: For virtual/abstract methods that many classes override, consider whether existing overrides actually return null. If they commonly do (like `Object.ToString()`), annotate the return as `T?` — callers need to know. If null overrides are vanishingly rare (like `Exception.Message`), annotate as `T`. When in doubt for broadly overridden virtuals, prefer `T?`. -- **`IEquatable` and `IComparable`**: Reference types should implement `IEquatable` and `IComparable` (with nullable `T`), because callers commonly pass null to `Equals` and `CompareTo`. -- **`Equals(object?)` overrides**: Add `[NotNullWhen(true)]` to the parameter of `Equals(object? obj)` overrides — if `Equals` returns `true`, the argument is guaranteed non-null. This lets callers skip redundant null checks after an equality test. - -> **Build checkpoint:** After annotating declarations, build and confirm zero CS8618/CS8625/CS8601 warnings remain before moving to nullable attributes. - -### Step 5: Apply nullable attributes for advanced scenarios - -When a simple `?` annotation cannot express the null contract, apply attributes from `System.Diagnostics.CodeAnalysis` — see [references/nullable-attributes.md](references/nullable-attributes.md) for the full attribute table (`[NotNullWhen]`, `[MaybeNullWhen]`, `[MemberNotNull]`, `[AllowNull]`, `[DisallowNull]`, `[DoesNotReturn]`, etc.) with usage guidance for each. - -> **Build checkpoint:** After applying nullable attributes, build to verify the attributes resolved the targeted warnings and did not introduce new ones. - -### Step 6: Clean up suppressions - -> **Optional:** Re-run `scripts/Get-NullableReadiness.ps1` to get current counts of `#nullable disable` directives, `!` operators, and `#pragma warning disable CS86xx` suppressions across the project. - -1. Search for any `#nullable disable` directives or `!` operators that were added as temporary workarounds. -2. For each one, determine whether the suppression is still needed. -3. Remove suppressions that are no longer necessary. For any that remain, add a comment explaining why. -4. Search for `#pragma warning disable CS86` to find suppressed nullable warnings and evaluate whether the underlying issue can be fixed instead. - -> **Build checkpoint:** After removing suppressions, build again — removing a `#nullable disable` or `!` may surface new warnings that need fixing. - -### Step 7: Validate - -1. Build the project and confirm zero nullable warnings. -2. Add `nullable` to the project file (or `Directory.Build.props` for the whole repo) to permanently prevent nullable regressions. This is the project-file equivalent of `dotnet build /warnaserror:nullable`. -3. Run existing tests to confirm no regressions. -4. If the project is a library, inspect the public API surface to verify that nullable annotations match the intended contracts (parameters that accept null are `T?`, parameters that reject null are `T`). - -> **Verify before claiming the migration is complete.** Zero warnings alone does not mean the migration is correct. Before reporting success: (1) spot-check public API signatures — confirm `?` annotations match actual design intent, not just compiler silence; (2) verify no `?.` operators were added that change runtime behavior (search for `?.` in the diff); (3) confirm no `ArgumentNullException` checks were removed; (4) check that `!` operators are rare and each has a justifying comment. - -## Validation - -- [ ] Project file(s) contain `enable` (or `#nullable enable` per-file for file-by-file strategy) -- [ ] Build produces zero CS86xx warnings -- [ ] `nullable` added to project file to prevent regressions -- [ ] Tests pass with no regressions -- [ ] No `#nullable disable` directives remain unless justified with a comment -- [ ] Null-forgiving operators (`!`) are rare, each with a justifying comment -- [ ] Public API signatures accurately reflect null contracts -- [ ] For public libraries: breaking changes documented in `nullable-breaking-changes.md` and reviewed by the user - -### Code review checklist - -Nullable migration changes require broader review than a typical diff: - -1. **Verify no behavior changes**: confirm that `?` and `!` are the only additions — no accidental `?.`, no removed null checks, no new branches. The generated IL should be unchanged except for nullable metadata. -2. **Review explicit annotation changes**: for every `?` added to a parameter or return type, confirm it matches the intended design. Does the method really accept null? Can it really return null? -3. **Review unchanged APIs in scope**: enabling `enable` implicitly makes every unannotated reference type in that scope non-nullable. Scan unchanged public members for parameters that actually do accept null but were not annotated. - -## Breaking Changes from NRT Annotations (Libraries) - -For libraries, see [references/breaking-changes.md](references/breaking-changes.md) — NRT annotations are part of the public API contract and incorrect annotations are source-breaking changes for consumers. - -## Common Pitfalls - -| Pitfall | Solution | -|---------|----------| -| Sprinkling `!` everywhere to silence warnings | The null-forgiving operator hides bugs. Add null checks or change the type to nullable instead | -| Marking everything `T?` to eliminate warnings quickly | Over-annotating with `?` defeats the purpose — callers must add unnecessary null checks. Only use `?` when null is a valid value | -| Constructor does not initialize all non-nullable members | Initialize fields and properties in every constructor, use `required` (C# 11+), or make the member nullable | -| Serialization bypasses constructors — non-nullable ≠ runtime safety | Serializers create objects without calling constructors, so non-nullable DTO properties can still be null at runtime. See "DTOs vs domain models" in Step 4 for detailed guidance | -| Generated code produces warnings | Generated files are excluded from nullable analysis automatically if they contain `` comments. If warnings persist, add `#nullable disable` at the top of the generated file or configure `.editorconfig` with `generated_code = true` | -| Multi-target projects and older TFMs | NRT annotations compile on older TFMs (e.g., .NET Standard 2.0) with C# 8.0+, but nullable attributes like `[NotNullWhen]` may not exist. Use a polyfill package such as `Nullable` from NuGet, or define the attributes internally | -| Warnings reappear after upgrading a dependency | The dependency added nullable annotations. This is expected and beneficial — fix the new warnings as in Steps 3–5 | -| Accidentally changing behavior while annotating | Adding `?` to a type or `!` to an expression is metadata-only and does not change generated IL. But replacing `obj.Method()` with `obj?.Method()` (null-conditional) changes runtime behavior — the call is silently skipped instead of throwing. Only use `?.` when you intentionally want to tolerate null, not as a quick fix for a warning | -| Adding `?` to a value type (enum, struct) | For reference types, `?` is a metadata annotation with no runtime effect. For value types like `int` or an enum, `?` changes the type to `Nullable`, altering the method signature, binary layout, and boxing behavior. Double-check that you are only adding `?` to reference types unless you truly intend to make a value type nullable | -| Removing existing null argument validation | Non-nullable annotations are compile-time only — callers can still pass null at runtime. Keep existing `ArgumentNullException` checks. See Step 4 for details | -| `var` infers nullability from the assigned expression | When using `var`, the inferred type includes nullability from the assigned expression, which can be surprising compared to explicitly declaring `T` vs `T?`. Flow analysis determines the actual null-state from that point forward, but the inferred declaration type may carry nullability you did not expect. If precise nullability at the declaration matters, use an explicit type instead of `var` | -| Consuming unannotated (nullable-oblivious) libraries | When a dependency has not opted into nullable annotations, the compiler treats all its types as "oblivious" — you get no warnings for dereferencing or assigning null. This gives a false sense of safety. Treat return values from oblivious APIs as potentially null, especially for methods that could conceptually return null (dictionary lookups, `FirstOrDefault`-style calls). Upgrade dependencies or wrap calls when possible | - -## Entity Framework Core Considerations - -If the project uses EF Core, see [references/ef-core.md](references/ef-core.md) — enabling NRTs can change database schema inference and migration output. - -## ASP.NET Core Considerations - -If the project uses ASP.NET Core, see [references/aspnet-core.md](references/aspnet-core.md) — enabling NRTs can change MVC model validation and JSON serialization behavior. - -## More Info - -- [Nullable reference types](https://learn.microsoft.com/dotnet/csharp/nullable-references) — overview of the feature, nullable contexts, and compiler analysis -- [Nullable reference types (C# reference)](https://learn.microsoft.com/dotnet/csharp/language-reference/builtin-types/nullable-reference-types) — language reference for nullable annotation and warning contexts -- [Nullable migration strategies](https://learn.microsoft.com/dotnet/csharp/nullable-migration-strategies) -- [Embracing Nullable Reference Types](https://devblogs.microsoft.com/dotnet/embracing-nullable-reference-types/) — Mads Torgersen's guidance on adoption timing and ecosystem considerations -- [Resolve nullable warnings](https://learn.microsoft.com/dotnet/csharp/language-reference/compiler-messages/nullable-warnings) -- [Attributes for nullable static analysis](https://learn.microsoft.com/dotnet/csharp/language-reference/attributes/nullable-analysis) -- [! (null-forgiving) operator](https://learn.microsoft.com/dotnet/csharp/language-reference/operators/null-forgiving) — language reference for the operator and when to use it -- [EF Core and nullable reference types](https://learn.microsoft.com/ef/core/miscellaneous/nullable-reference-types) -- [.NET Runtime nullable annotation guidelines](https://github.com/dotnet/runtime/blob/main/docs/coding-guidelines/api-guidelines/nullability.md) — the annotation principles used when annotating the .NET libraries themselves diff --git a/.agents/skills/migrate-nullable-references/references/aspnet-core.md b/.agents/skills/migrate-nullable-references/references/aspnet-core.md deleted file mode 100644 index ebedcb7..0000000 --- a/.agents/skills/migrate-nullable-references/references/aspnet-core.md +++ /dev/null @@ -1,17 +0,0 @@ -# ASP.NET Core Considerations - -ASP.NET Core reads nullable annotations at runtime to drive model validation and serialization behavior. Enabling NRTs in an ASP.NET Core project can change request validation outcomes, not just compiler warnings: - -- **MVC model validation treats non-nullable properties as `[Required]`**: When NRTs are enabled, ASP.NET Core MVC and Web API implicitly add `[Required(AllowEmptyStrings = true)]` to every non-nullable reference type property in DTOs and view models. A `string Name` property that previously accepted null from JSON or form posts will now return a 400 Bad Request. Review all model classes when enabling NRTs. To disable this behavior during gradual migration, set `SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true` in `AddControllers` options. -- **Minimal API parameter optionality changes with NRTs**: When NRTs are enabled, minimal API parameter binding uses nullable annotations to determine whether a parameter is required or optional. A `string name` parameter that was previously treated as optional (accepting null) becomes required and returns a 400 Bad Request if missing. To preserve the previous behavior, explicitly mark the parameter as nullable (`string? name`). Review all minimal API endpoint parameters when enabling NRTs. See [optional parameters](https://learn.microsoft.com/aspnet/core/fundamentals/minimal-apis/parameter-binding#optional-parameters). -- **Enable `JsonSerializerOptions.RespectNullableAnnotations = true` (.NET 9+)**: For .NET 9+ projects, always enable `RespectNullableAnnotations` (along with `RespectRequiredConstructorParameters`) to align runtime serialization behavior with your NRT annotations. Without this, `System.Text.Json` silently assigns `null` to non-nullable properties, undermining compile-time null safety. When enabled, the serializer throws `JsonException` when a non-nullable property receives an explicit `null` during deserialization, or emits `null` for a non-nullable property during serialization. Be aware this enforcement has hard limitations rooted in how NRTs are represented in IL. It does **not** cover: - - Collection element types (`List` and `List` are indistinguishable via reflection) - - Dictionary value types (`Dictionary` vs `Dictionary`) - - Top-level types passed directly to `Deserialize` - - Generic type parameter nullability - - For these gaps, use manual validation or custom converters. Do not rely on `RespectNullableAnnotations` alone for complete null safety in your JSON layer. -- **Use `#nullable disable`, not `#nullable disable warnings` on model files**: Just as with EF Core, `#nullable disable warnings` only suppresses compiler diagnostics — the annotations remain active and MVC still reads them via reflection to infer `[Required]`. Use `#nullable disable` to fully opt out for files not yet migrated. -- **Razor Pages `[BindProperty]` properties**: Properties decorated with `[BindProperty]` (e.g., `public InputModel Input { get; set; }`) are populated by model binding during POST requests — similar to how EF Core initializes `DbSet` properties. Initialize with `= default!` or suppress CS8618 with a pragma. After a `ModelState.IsValid` check succeeds, sub-properties with `[Required]` can be accessed with the null-forgiving operator (`!`), since validation guarantees they are non-null. -- **Collection properties in ViewModels and DTOs**: Prefer non-nullable with an empty initializer (`= new List()`) over nullable. An empty collection means "no items"; null means "unknown/not loaded." This avoids forcing every consumer to null-check before iterating and matches the EF Core convention for collection navigations. -- **Avoid `?.` followed by `!`**: The pattern `obj?.Property!` is contradictory — `?.` handles the null case by producing null, then `!` immediately asserts the result is non-null. Use either `obj!.Property` (assert non-null, then access) or `obj?.Property` (conditionally access and handle null downstream). The `?.` + `!` combination often appears in Razor Page code-behind when accessing `[BindProperty]` model sub-properties; prefer `obj!.Property` after validation confirms the model is bound. diff --git a/.agents/skills/migrate-nullable-references/references/breaking-changes.md b/.agents/skills/migrate-nullable-references/references/breaking-changes.md deleted file mode 100644 index a8790eb..0000000 --- a/.agents/skills/migrate-nullable-references/references/breaking-changes.md +++ /dev/null @@ -1,8 +0,0 @@ -# Breaking Changes from NRT Annotations (Libraries) - -For libraries consumed by other projects, NRT annotations are part of the public API contract. Incorrect annotations are source-breaking changes for consumers: - -- **Making a parameter non-nullable when it should be nullable**: If consumers previously passed null to a parameter and the method handled it gracefully, marking that parameter as `T` (non-nullable) causes compile warnings or errors for those callers. For example, annotating a logging enricher's `value` parameter as `object` instead of `object?` when the method has always accepted null values would break every caller that passes null. -- **Implicit non-nullability of unannotated types**: Enabling `enable` implicitly makes every unannotated reference-type parameter non-nullable. If the method previously accepted null without throwing, this is a silent contract change. Scan all public methods for parameters that tolerate null. -- **Return types that can be null**: If a method can return null, the return type must be `T?`. Marking it as `T` hides a potential `NullReferenceException` from callers who trust the annotation. -- **Ship annotations in a minor version, not a patch**: Because annotations can cause new warnings for consumers (especially those using `TreatWarningsAsErrors`), treat the NRT migration as a minor version bump, not a patch. Document the change in release notes. diff --git a/.agents/skills/migrate-nullable-references/references/ef-core.md b/.agents/skills/migrate-nullable-references/references/ef-core.md deleted file mode 100644 index 7b04bd8..0000000 --- a/.agents/skills/migrate-nullable-references/references/ef-core.md +++ /dev/null @@ -1,18 +0,0 @@ -# Entity Framework Core Considerations - -EF Core uses nullable annotations to infer database schema. Enabling NRTs in a project that uses EF Core has effects beyond compiler warnings: - -- **Schema changes from annotations**: When NRTs are enabled, EF Core treats `string` properties as required (NOT NULL) columns and `string?` as optional (NULL) columns. If you enable NRTs on an existing model without reviewing every entity property, running `Add-Migration` can generate migrations that make previously nullable columns required — potentially causing data loss if those columns already store nulls. -- **Always review generated migrations**: After enabling NRTs on entity classes, run `Add-Migration` and carefully inspect the output before applying it. Look for unexpected `AlterColumn` calls that change column nullability. -- **Navigation properties**: Required navigation properties present a design choice because they are null until loaded. The official EF Core docs describe three approaches: **(a)** Non-nullable with `= null!` — appropriate when accessing an unloaded navigation is a programmer error; **(b)** Nullable (`public Order? Order { get; set; }`) — appropriate when code legitimately checks whether the navigation is loaded; **(c)** Non-nullable property wrapping a nullable backing field that throws `InvalidOperationException` on uninitialized access — the strictest pattern. Collection navigations should always be non-nullable (initialize to an empty collection, e.g., `= new List()`; an empty collection means no related entities exist, but the list itself should never be null). -- **Migrate entity classes carefully**: Consider annotating entity model classes one at a time rather than enabling NRTs project-wide, to control the scope of schema impact. -- **Use `#nullable disable`, not `#nullable disable warnings` on entity files**: `#nullable disable warnings` only suppresses compiler warnings — the nullable annotations remain active and EF Core still reads them via reflection. This means properties without `?` are still treated as required, potentially altering schema. To fully opt entity files out of NRT effects, use `#nullable disable` which disables both warnings and the annotation context. -- **Private parameterless constructors — always pair `#pragma` disable with restore**: When suppressing CS8618 for a private parameterless constructor required by EF Core, always pair `#pragma warning disable CS8618` with `#pragma warning restore CS8618` immediately after the constructor. Without `restore`, the suppression leaks to all subsequent members in the file — any new property or constructor added later will silently skip the CS8618 check. Example: - ```csharp - #pragma warning disable CS8618 // Required by Entity Framework - private Order() { } - #pragma warning restore CS8618 - ``` - As an alternative, use `= null!` on each non-nullable property instead of a pragma — this is more explicit and does not risk suppression leakage, but is more verbose for entities with many properties. -- **DbSet properties**: Keep `DbSet` properties non-nullable — EF Core always initializes them. EF Core 7.0+ (`.NET 7`) automatically suppresses CS8618 for DbSet properties. On older versions, initialize with `= null!` or use a read-only expression body: `public DbSet Customers => Set();`. -- **LINQ queries with optional navigations**: EF Core translates LINQ queries to SQL, so navigating through an optional relationship in `Where` or `Include` won't cause a `NullReferenceException` at runtime — EF handles the null case server-side. However, the compiler doesn't know this and will warn. Use the null-forgiving operator in these expressions: `.Where(o => o.OptionalNav!.Prop == "foo")` and `.Include(o => o.OptionalNav!).ThenInclude(n => n.Child)`. diff --git a/.agents/skills/migrate-nullable-references/references/nullable-attributes.md b/.agents/skills/migrate-nullable-references/references/nullable-attributes.md deleted file mode 100644 index 3a2e00e..0000000 --- a/.agents/skills/migrate-nullable-references/references/nullable-attributes.md +++ /dev/null @@ -1,19 +0,0 @@ -# Nullable Attributes Reference - -When a simple `?` annotation cannot express the null contract, use attributes from `System.Diagnostics.CodeAnalysis`: - -| Attribute | Use case | -|-----------|----------| -| `[NotNullWhen(true/false)]` | `TryGet` or `IsNullOrEmpty` patterns — the argument is not null when the method returns the specified bool. For `Try` methods with a **non-generic** out parameter, declare the parameter nullable and use `[NotNullWhen(true)] out MyType? result` — it is `null` on failure and non-null on success. Also add to `Equals(object? obj)` overrides to indicate the argument is non-null when returning `true` | -| `[MaybeNullWhen(true/false)]` | For `Try` methods with a **generic** out parameter, keep the parameter non-nullable and use `[MaybeNullWhen(false)] out T result` — the value may be `default` (null for reference types) on failure. Using `[NotNullWhen]` with `T?` here would change value-type signatures to `Nullable` | -| `[NotNull]` | A nullable parameter is guaranteed non-null when the method returns (e.g., a `ThrowIfNull` helper) | -| `[MaybeNull]` | A non-nullable generic return might be `default` (null). Rare in practice — prefer `T?` when possible. Reserve for cases like `AsyncLocal.Value` where `T?` is wrong because setting to null is invalid when `T` is non-nullable | -| `[AllowNull]` | A non-nullable property setter accepts null (e.g., falls back to a default value) | -| `[DisallowNull]` | A nullable property should never be explicitly set to null | -| `[MemberNotNull(nameof(...))]` | A helper method guarantees that specific members are non-null after it returns. When initializing multiple fields, prefer multiple `[MemberNotNull("field1")]` `[MemberNotNull("field2")]` attributes over one `[MemberNotNull("field1", "field2")]` — the `params` overload is not CLS-compliant | -| `[NotNullIfNotNull("paramName")]` | The return is non-null if the named parameter is non-null | -| `[DoesNotReturn]` | The method always throws — code after the call is unreachable | - -Add `using System.Diagnostics.CodeAnalysis;` where needed. - -> **Caution:** The compiler does not warn when nullable attributes are misapplied — for example, `[DisallowNull]` on an already non-nullable parameter or `[MaybeNull]` on a by-value input parameter (not `ref`/`out`) are silently ignored. Verify each attribute is placed where it has an effect. diff --git a/.agents/skills/migrate-nullable-references/scripts/Get-NullableReadiness.ps1 b/.agents/skills/migrate-nullable-references/scripts/Get-NullableReadiness.ps1 deleted file mode 100644 index ac912f5..0000000 --- a/.agents/skills/migrate-nullable-references/scripts/Get-NullableReadiness.ps1 +++ /dev/null @@ -1,487 +0,0 @@ -<# -.SYNOPSIS - Scans a C# project or solution for nullable reference type (NRT) readiness. - -.DESCRIPTION - Reports project-level NRT settings (, , , - ) and source-level counts (#nullable directives, null-forgiving - operators, #pragma warning disable CS86xx) to help assess migration status. - - Automates the manual checks in Steps 1 and 6 of the migrate-nullable-references skill. - -.PARAMETER Path - Path to a .csproj, .sln, or directory. Defaults to the current directory. - -.PARAMETER Json - Output as JSON instead of a human-readable summary. - -.PARAMETER Recurse - When Path is a directory (not a .sln), scan recursively for all .csproj files. - -.EXAMPLE - ./Get-NullableReadiness.ps1 - Scans the current directory for a .sln or .csproj and reports NRT readiness. - -.EXAMPLE - ./Get-NullableReadiness.ps1 -Path ./src/MyLib/MyLib.csproj - Scans a single project. - -.EXAMPLE - ./Get-NullableReadiness.ps1 -Path ./src -Recurse -Json - Scans all projects under ./src and outputs JSON. - -.NOTES - Example output BEFORE NRT migration: - - === NRT Readiness Report === - Project: System.Text.RegularExpressions - Path: src\System.Text.RegularExpressions.csproj - : (not set) - : latest (inherited) - : (not set) - Warning enforcement: all warnings as errors - Source files: 39 - #nullable enable: 1 - #nullable disable: 0 - #pragma CS86xx: 0 - ! operators (approx): 0 - Uninit ref fields: ~322 (estimated CS8618 warnings) - Migration progress: 1/39 files (2.6%) - Migration work needed: - CaptureCollection.cs: ~6 uninit fields - GroupCollection.cs: ~9 uninit fields - .... - === Summary === - Projects scanned: 1 - NRT enabled: 0/1 - Total .cs files: 39 - Total #nullable disable: 0 - Total #pragma CS86xx: 0 - Total ! operators: 0 - Total uninit ref fields: ~322 (estimated CS8618 warnings) - - Example output AFTER NRT migration (same project, all 502 CS86xx warnings resolved): - - === NRT Readiness Report === - Project: System.Text.RegularExpressions - Path: src\System.Text.RegularExpressions.csproj - : enable - : latest (inherited) - : (not set) - Warning enforcement: all warnings as errors - Source files: 39 - #nullable enable: 1 - #nullable disable: 0 - #pragma CS86xx: 0 - ! operators (approx): 192 - null!/default!: 47 - assertions: 145 - Suppression audit (review ! operators for possible removal): - Match.cs: 7 ! - Regex.Cache.cs: 22 ! - Regex.cs: 11 ! - RegexCompiler.cs: 31 ! (24 null!/default!, 7 assertions) - ... - === Summary === - Projects scanned: 1 - NRT enabled: 1/1 - Total .cs files: 39 - Total #nullable disable: 0 - Total #pragma CS86xx: 0 - Total ! operators: 192 - null!/default!: 47 - assertions: 145 -#> - -[CmdletBinding()] -param( - [string]$Path = ".", - [switch]$Json, - [switch]$Recurse -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = "Stop" - -#region Helpers - -function Get-ProjectFiles { - param([string]$InputPath, [switch]$Recurse) - - $resolved = Resolve-Path $InputPath -ErrorAction Stop - - if (Test-Path $resolved -PathType Leaf) { - $ext = [System.IO.Path]::GetExtension($resolved) - if ($ext -eq ".csproj") { - return @($resolved.Path) - } - if ($ext -eq ".sln") { - return Get-ProjectsFromSolution $resolved.Path - } - Write-Error "Unsupported file type: $ext. Provide a .csproj, .sln, or directory." - } - - # Directory - if ($Recurse) { - $projects = Get-ChildItem -Path $resolved -Filter "*.csproj" -Recurse | Select-Object -ExpandProperty FullName - } else { - # Look for .sln first, then .csproj in the directory - $sln = Get-ChildItem -Path $resolved -Filter "*.sln" -File | Select-Object -First 1 - if ($sln) { - return Get-ProjectsFromSolution $sln.FullName - } - $projects = Get-ChildItem -Path $resolved -Filter "*.csproj" -File | Select-Object -ExpandProperty FullName - } - - if (-not $projects -or $projects.Count -eq 0) { - Write-Error "No .csproj files found in '$resolved'." - } - return $projects -} - -function Get-ProjectsFromSolution { - param([string]$SlnPath) - - $slnDir = Split-Path $SlnPath -Parent - $projects = @() - foreach ($line in Get-Content $SlnPath) { - if ($line -match 'Project\("[^"]*"\)\s*=\s*"[^"]*"\s*,\s*"([^"]*\.csproj)"') { - $relPath = $Matches[1] -replace '\\', [System.IO.Path]::DirectorySeparatorChar - $fullPath = Join-Path $slnDir $relPath - if (Test-Path $fullPath) { - $projects += (Resolve-Path $fullPath).Path - } - } - } - return $projects -} - -function Read-ProjectSettings { - param([string]$CsprojPath) - - $xml = [xml](Get-Content $CsprojPath -Raw) - $ns = $xml.DocumentElement.NamespaceURI - - # Check for Directory.Build.props in parent directories - $propsSettings = Find-DirectoryBuildProps (Split-Path $CsprojPath -Parent) - - $nullable = Select-XmlValue $xml "//Nullable" $ns - $langVersion = Select-XmlValue $xml "//LangVersion" $ns - $tfm = Select-XmlValue $xml "//TargetFramework" $ns - $tfms = Select-XmlValue $xml "//TargetFrameworks" $ns - $warningsAsErrors = Select-XmlValue $xml "//WarningsAsErrors" $ns - $treatWarningsAsErrors = Select-XmlValue $xml "//TreatWarningsAsErrors" $ns - - # Fall back to Directory.Build.props values - if (-not $nullable -and $propsSettings.Nullable) { $nullable = $propsSettings.Nullable + " (inherited)" } - if (-not $langVersion -and $propsSettings.LangVersion) { $langVersion = $propsSettings.LangVersion + " (inherited)" } - if (-not $warningsAsErrors -and $propsSettings.WarningsAsErrors) { $warningsAsErrors = $propsSettings.WarningsAsErrors + " (inherited)" } - if (-not $treatWarningsAsErrors -and $propsSettings.TreatWarningsAsErrors) { $treatWarningsAsErrors = $propsSettings.TreatWarningsAsErrors + " (inherited)" } - - $framework = if ($tfms) { $tfms } elseif ($tfm) { $tfm } else { "(not set)" } - - $warningEnforcement = "none" - if ($treatWarningsAsErrors -and $treatWarningsAsErrors -match "true") { - $warningEnforcement = "all warnings as errors" - } elseif ($warningsAsErrors -and $warningsAsErrors -match "nullable") { - $warningEnforcement = "nullable warnings as errors" - } - - return [PSCustomObject]@{ - Nullable = if ($nullable) { $nullable } else { "(not set)" } - LangVersion = if ($langVersion) { $langVersion } else { "(not set)" } - TargetFramework = $framework - WarningEnforcement = $warningEnforcement - } -} - -function Select-XmlValue { - param($Xml, [string]$XPath, [string]$Namespace) - - if ($Namespace) { - $nsmgr = New-Object System.Xml.XmlNamespaceManager($Xml.NameTable) - $nsmgr.AddNamespace("ns", $Namespace) - $nsXPath = $XPath -replace '//', '//ns:' -replace '/ns:ns:', '/ns:' - $node = $Xml.SelectSingleNode($nsXPath, $nsmgr) - } else { - $node = $Xml.SelectSingleNode($XPath) - } - - if ($node) { return $node.InnerText.Trim() } - return $null -} - -function Find-DirectoryBuildProps { - param([string]$StartDir) - - $result = [PSCustomObject]@{ - Nullable = $null - LangVersion = $null - WarningsAsErrors = $null - TreatWarningsAsErrors = $null - } - - $dir = $StartDir - while ($dir) { - $propsPath = Join-Path $dir "Directory.Build.props" - if (Test-Path $propsPath) { - $xml = [xml](Get-Content $propsPath -Raw) - $ns = $xml.DocumentElement.NamespaceURI - if (-not $result.Nullable) { $result.Nullable = Select-XmlValue $xml "//Nullable" $ns } - if (-not $result.LangVersion) { $result.LangVersion = Select-XmlValue $xml "//LangVersion" $ns } - if (-not $result.WarningsAsErrors) { $result.WarningsAsErrors = Select-XmlValue $xml "//WarningsAsErrors" $ns } - if (-not $result.TreatWarningsAsErrors) { $result.TreatWarningsAsErrors = Select-XmlValue $xml "//TreatWarningsAsErrors" $ns } - } - $parent = Split-Path $dir -Parent - if ($parent -eq $dir) { break } - $dir = $parent - } - - return $result -} - -function Scan-SourceFiles { - param([string]$CsprojPath) - - $projectDir = Split-Path $CsprojPath -Parent - $csFiles = @(Get-ChildItem -Path $projectDir -Filter "*.cs" -Recurse -File | - Where-Object { $_.FullName -notmatch '[\\/](obj|bin)[\\/]' }) - - $totalFiles = $csFiles.Count - $filesWithNullableEnable = 0 - $totalNullableDisable = 0 - $totalNullableEnable = 0 - $totalPragmaDisable = 0 - $totalBangOperator = 0 - $totalBangNullInit = 0 - $totalBangAssertions = 0 - $totalUninitFields = 0 - $fileDetails = @() - - foreach ($file in $csFiles) { - $content = Get-Content $file.FullName -Raw -ErrorAction SilentlyContinue - if (-not $content) { continue } - - $lines = $content -split '\r?\n' - - $nullableDisable = @($lines | Where-Object { $_ -match '^\s*#nullable\s+disable' }).Count - $nullableEnable = @($lines | Where-Object { $_ -match '^\s*#nullable\s+enable' }).Count - $pragmaDisable = @($lines | Where-Object { $_ -match '#pragma\s+warning\s+disable\s+CS86' }).Count - - # Count null-forgiving operators (approximate). - # Strip string literals before comments to avoid false positives — a string - # like "http://..." contains // that would otherwise be mis-parsed as a comment. - # Then match ! preceded by ), ], >, or a word character, not followed by =. - $strippedContent = $content - $strippedContent = [regex]::Replace($strippedContent, '(?\w])!(?!=)') - $bangCount = $bangMatches.Count - - # Categorize: null! initializers (= null!, => null!, default!) vs other assertions - $nullInitCount = ([regex]::Matches($strippedContent, '(?:=\s*null!|=>\s*null!|default!)')).Count - $bangAssertionCount = $bangCount - $nullInitCount - - # Estimate uninitialised reference-type fields and auto-properties (approximate CS8618 predictor). - # Matches field declarations ending in ; without an = initializer, and auto-properties - # without initializers, excluding value types and events. - $valueTypes = 'bool|byte|sbyte|char|decimal|double|float|int|uint|long|ulong|short|ushort|nint|nuint|void|IntPtr|UIntPtr|Guid|DateTime|DateTimeOffset|TimeSpan|CancellationToken' - $uninitFields = @($lines | Where-Object { - ( - # Field declarations: type name; - ($_ -match '^\s*(private|protected|internal|public|static|readonly|\s)+\s+\w[\w<>\[\],\?\.]*\s+\w+\s*;') -or - # Auto-properties: type Name { get; set; } or { get; } - ($_ -match '^\s*(private|protected|internal|public|static|virtual|override|abstract|\s)+\s+\w[\w<>\[\],\?\.]*\s+\w+\s*\{\s*get;') - ) -and - $_ -notmatch '=' -and - $_ -notmatch '\brequired\b' -and - $_ -notmatch "^\s*(private|protected|internal|public|static|readonly|virtual|override|abstract|\s)+\s+($valueTypes)\b" -and - $_ -notmatch '^\s*(private|protected|internal|public|static|readonly|\s)+\s*(const|event)\b' - }).Count - - if ($nullableEnable -gt 0) { $filesWithNullableEnable++ } - $totalNullableDisable += $nullableDisable - $totalNullableEnable += $nullableEnable - $totalPragmaDisable += $pragmaDisable - $totalBangOperator += $bangCount - $totalBangNullInit += $nullInitCount - $totalBangAssertions += $bangAssertionCount - $totalUninitFields += $uninitFields - - $relativePath = $file.FullName.Substring($projectDir.Length).TrimStart([System.IO.Path]::DirectorySeparatorChar) - - if ($nullableDisable -gt 0 -or $pragmaDisable -gt 0 -or $bangCount -gt 5 -or $uninitFields -gt 5) { - $fileDetails += [PSCustomObject]@{ - File = $relativePath - NullableDisable = $nullableDisable - PragmaDisable = $pragmaDisable - BangOperators = $bangCount - BangNullInit = $nullInitCount - BangAssertions = $bangAssertionCount - UninitFields = $uninitFields - } - } - } - - return [PSCustomObject]@{ - TotalFiles = $totalFiles - FilesWithEnable = $filesWithNullableEnable - NullableDisableCount = $totalNullableDisable - NullableEnableCount = $totalNullableEnable - PragmaDisableCount = $totalPragmaDisable - BangOperatorCount = $totalBangOperator - BangNullInitCount = $totalBangNullInit - BangAssertionCount = $totalBangAssertions - UninitFieldCount = $totalUninitFields - FilesOfInterest = $fileDetails - } -} - -#endregion - -#region Main - -$projectFiles = Get-ProjectFiles -InputPath $Path -Recurse:$Recurse - -$results = @() - -foreach ($proj in $projectFiles) { - $projName = [System.IO.Path]::GetFileNameWithoutExtension($proj) - - Write-Verbose "Scanning $projName..." - - $settings = Read-ProjectSettings $proj - $sourceStats = Scan-SourceFiles $proj - - $results += [PSCustomObject]@{ - Project = $projName - Path = $proj - Nullable = $settings.Nullable - LangVersion = $settings.LangVersion - TargetFramework = $settings.TargetFramework - WarningEnforcement = $settings.WarningEnforcement - TotalCsFiles = $sourceStats.TotalFiles - FilesWithEnable = $sourceStats.FilesWithEnable - NullableDisable = $sourceStats.NullableDisableCount - NullableEnable = $sourceStats.NullableEnableCount - PragmaDisableCS86 = $sourceStats.PragmaDisableCount - BangOperators = $sourceStats.BangOperatorCount - BangNullInit = $sourceStats.BangNullInitCount - BangAssertions = $sourceStats.BangAssertionCount - UninitFields = $sourceStats.UninitFieldCount - FilesOfInterest = $sourceStats.FilesOfInterest - } -} - -if ($Json) { - $results | ConvertTo-Json -Depth 4 - return -} - -# Human-readable output -Write-Host "" -Write-Host "=== NRT Readiness Report ===" -ForegroundColor Cyan -Write-Host "" - -foreach ($r in $results) { - Write-Host "Project: $($r.Project)" -ForegroundColor Yellow - Write-Host " Path: $($r.Path)" - Write-Host " : $($r.Nullable)" - Write-Host " : $($r.LangVersion)" - Write-Host " : $($r.TargetFramework)" - Write-Host " Warning enforcement: $($r.WarningEnforcement)" - Write-Host "" - Write-Host " Source files: $($r.TotalCsFiles)" - Write-Host " #nullable enable: $($r.NullableEnable)" - Write-Host " #nullable disable: $($r.NullableDisable)" - Write-Host " #pragma CS86xx: $($r.PragmaDisableCS86)" - Write-Host " ! operators (approx): $($r.BangOperators)" - if ($r.BangOperators -gt 0) { - Write-Host " null!/default!: $($r.BangNullInit)" - Write-Host " assertions: $($r.BangAssertions)" - } - - if ($r.UninitFields -gt 0 -and $r.Nullable -notmatch "enable") { - Write-Host " Uninit ref fields: ~$($r.UninitFields) (estimated CS8618 warnings)" -ForegroundColor DarkYellow - } - - if ($r.FilesWithEnable -gt 0 -and $r.Nullable -notmatch "enable") { - $pct = [math]::Round(($r.FilesWithEnable / $r.TotalCsFiles) * 100, 1) - Write-Host " Migration progress: $($r.FilesWithEnable)/$($r.TotalCsFiles) files ($pct%)" -ForegroundColor Green - } - - # Per-file details — context-dependent heading and content - $nrtEnabled = $r.Nullable -match "enable" - $interestFiles = @($r.FilesOfInterest) - - # Filter to files with displayable parts - $displayFiles = @() - foreach ($f in $interestFiles) { - $parts = @() - if ($f.NullableDisable -gt 0) { $parts += "$($f.NullableDisable) #nullable disable" } - if ($f.PragmaDisable -gt 0) { $parts += "$($f.PragmaDisable) #pragma" } - if ($f.BangOperators -gt 5) { - $bangDetail = "$($f.BangOperators) !" - if ($f.BangNullInit -gt 0) { - $bangDetail += " ($($f.BangNullInit) null!/default!, $($f.BangAssertions) assertions)" - } - $parts += $bangDetail - } - if (-not $nrtEnabled -and $f.UninitFields -gt 5) { $parts += "~$($f.UninitFields) uninit fields" } - if ($parts.Count -gt 0) { - $displayFiles += [PSCustomObject]@{ File = $f.File; Detail = ($parts -join ', ') } - } - } - - if ($displayFiles.Count -gt 0) { - Write-Host "" - if (-not $nrtEnabled) { - Write-Host " Migration work needed:" -ForegroundColor Magenta - } elseif ($r.NullableDisable -gt 0 -or $r.PragmaDisableCS86 -gt 0) { - Write-Host " Remaining cleanup:" -ForegroundColor Magenta - } else { - Write-Host " Suppression audit (review ! operators for possible removal):" -ForegroundColor DarkYellow - } - foreach ($df in $displayFiles) { - Write-Host " $($df.File): $($df.Detail)" - } - } - - Write-Host "" -} - -# Summary -if (@($results).Count -gt 1) { - $total = [PSCustomObject]@{ - Projects = @($results).Count - CsFiles = ($results | Measure-Object -Property TotalCsFiles -Sum).Sum - NullDisable = ($results | Measure-Object -Property NullableDisable -Sum).Sum - PragmaCS86 = ($results | Measure-Object -Property PragmaDisableCS86 -Sum).Sum - BangOps = ($results | Measure-Object -Property BangOperators -Sum).Sum - BangNullInit = ($results | Measure-Object -Property BangNullInit -Sum).Sum - BangAssert = ($results | Measure-Object -Property BangAssertions -Sum).Sum - UninitFields = ($results | Measure-Object -Property UninitFields -Sum).Sum - NrtEnabled = @($results | Where-Object { $_.Nullable -match "enable" }).Count - } - - Write-Host "=== Summary ===" -ForegroundColor Cyan - Write-Host " Projects scanned: $($total.Projects)" - Write-Host " NRT enabled: $($total.NrtEnabled)/$($total.Projects)" - Write-Host " Total .cs files: $($total.CsFiles)" - Write-Host " Total #nullable disable: $($total.NullDisable)" - Write-Host " Total #pragma CS86xx: $($total.PragmaCS86)" - Write-Host " Total ! operators: $($total.BangOps)" - if ($total.BangOps -gt 0) { - Write-Host " null!/default!: $($total.BangNullInit)" - Write-Host " assertions: $($total.BangAssert)" - } - if ($total.UninitFields -gt 0) { - Write-Host " Total uninit ref fields: ~$($total.UninitFields) (estimated CS8618 warnings)" - } - Write-Host "" -} - -#endregion diff --git a/.agents/skills/msbuild-antipatterns/SKILL.md b/.agents/skills/msbuild-antipatterns/SKILL.md deleted file mode 100644 index 3daa077..0000000 --- a/.agents/skills/msbuild-antipatterns/SKILL.md +++ /dev/null @@ -1,409 +0,0 @@ ---- -name: msbuild-antipatterns -description: "Detect and fix MSBuild anti-patterns in project and build files. USE WHEN asked to review, audit, lint, clean up, or code-review a .csproj/.vbproj/.fsproj/.props/.targets/.proj (or Directory.Build.props/.targets) file, when asked 'is this project file correct?' or 'what's wrong with my build file?', or when hunting subtle build bugs caused by how a project is authored. Each anti-pattern has a symptom and a concrete BAD→GOOD fix. DO NOT USE FOR: non-MSBuild build systems (npm, Maven, CMake), or migrating a project to SDK-style (use msbuild-modernization)." -license: MIT ---- - -# MSBuild Anti-Pattern Catalog - -A numbered catalog of common MSBuild anti-patterns. Each entry follows the format: - -- **Smell**: What to look for -- **Why it's bad**: Impact on builds, maintainability, or correctness -- **Fix**: Concrete transformation - -Use this catalog when scanning project files for improvements. - ---- - -## AP-01: `` for Operations That Have Built-in Tasks - -**Smell**: ``, ``, `` - -**Why it's bad**: Built-in tasks are cross-platform, support incremental build, emit structured logging, and handle errors consistently. `` is opaque to MSBuild. - -```xml - - - - - - - - - - - - - -``` - -**Built-in task alternatives:** - -| Shell Command | MSBuild Task | -|--------------|--------------| -| `mkdir` | `` | -| `copy` / `cp` | `` | -| `del` / `rm` | `` | -| `move` / `mv` | `` | -| `echo text > file` | `` | -| `touch` | `` | -| `xcopy /s` | `` with item globs | - ---- - -## AP-02: Unquoted Condition Expressions - -**Smell**: `Condition="$(Foo) == Bar"` — either side of a comparison is unquoted. - -**Why it's bad**: If the property is empty or contains spaces/special characters, the condition evaluates incorrectly or throws a parse error. MSBuild requires single-quoted strings for reliable comparisons. - -```xml - - - true - - - - - true - -``` - -**Rule**: Always quote **both** sides of `==` and `!=` comparisons with single quotes. - ---- - -## AP-03: Hardcoded Absolute Paths - -**Smell**: Paths like `C:\tools\`, `D:\packages\`, `/usr/local/bin/` in project files. - -**Why it's bad**: Breaks on other machines, CI environments, and other operating systems. Not relocatable. - -```xml - - - C:\tools\mytool\mytool.exe - - - - - - $(MSBuildThisFileDirectory)tools\mytool\mytool.exe - - -``` - -**Preferred path properties:** - -| Property | Meaning | -|----------|---------| -| `$(MSBuildThisFileDirectory)` | Directory of the current .props/.targets file | -| `$(MSBuildProjectDirectory)` | Directory of the .csproj | -| `$([MSBuild]::GetDirectoryNameOfFileAbove(...))` | Walk up to find a marker file | -| `$([MSBuild]::NormalizePath(...))` | Combine and normalize path segments | - ---- - -## AP-04: Restating SDK Defaults - -**Smell**: Properties set to values that the .NET SDK already provides by default. - -**Why it's bad**: Adds noise, hides intentional overrides, and makes it harder to identify what's actually customized. When defaults change in newer SDKs, the redundant properties may silently pin old behavior. - -```xml - - - Library - true - true - MyLib - MyLib - true - - - - - net8.0 - -``` - ---- - -## AP-05: Manual File Listing in SDK-Style Projects - -**Smell**: ``, `` in SDK-style projects. - -**Why it's bad**: SDK-style projects automatically glob `**/*.cs` (and other file types). Explicit listing is redundant, creates merge conflicts, and new files may be accidentally missed if not added to the list. - -```xml - - - - - - - - - - - -``` - -**Exception**: Non-SDK-style (legacy) projects require explicit file includes. If migrating, see `msbuild-modernization` skill. - -**Exception (F# / `.fsproj`)**: F# compilation is order-dependent — the compiler processes `` items sequentially and a file can only reference types/modules declared in files listed above it. `.fsproj` files must therefore list every source file explicitly, in dependency order (utility/leaf modules at the top, the entry point such as `Program.fs` at the bottom). If a `.fsi` signature file is used, it must appear **immediately before** its companion `.fs` implementation file. - ---- - -## AP-06: Using `` with HintPath for NuGet Packages - -**Smell**: `` - -**Why it's bad**: This is the legacy `packages.config` pattern. It doesn't support transitive dependencies, version conflict resolution, or automatic restore. The `packages/` folder must be committed or restored separately. - -```xml - - - - ..\packages\Newtonsoft.Json.13.0.3\lib\netstandard2.0\Newtonsoft.Json.dll - - - - - - - -``` - -**Note**: `` without HintPath is still valid for .NET Framework GAC assemblies like `WindowsBase`, `PresentationCore`, etc. - ---- - -## AP-07: Missing `PrivateAssets="all"` on Analyzer/Tool Packages - -**Smell**: `` without `PrivateAssets="all"`. - -**Why it's bad**: Without `PrivateAssets="all"`, analyzer and build-tool packages flow as transitive dependencies to consumers of your library. Consumers get unwanted analyzers or build-time tools they didn't ask for. - -See [`references/private-assets.md`](references/private-assets.md) for BAD/GOOD examples and the full list of packages that need this. - ---- - -## AP-08: Copy-Pasted Properties Across Multiple .csproj Files - -**Smell**: The same `` block appears in 3+ project files. - -**Why it's bad**: Maintenance burden — a change must be made in every file. Inconsistencies creep in over time. - -```xml - - - - enable - true - enable - - - - - - - enable - true - enable - - -``` - -See `directory-build-organization` skill for full guidance on structuring `Directory.Build.props` / `Directory.Build.targets`. - ---- - -## AP-09: Scattered Package Versions Without Central Package Management - -**Smell**: `` with different versions of the same package across projects. - -**Why it's bad**: Version drift — different projects use different versions of the same package, leading to runtime mismatches, unexpected behavior, or diamond dependency conflicts. - -```xml - - - - - -``` - -**Fix:** Use Central Package Management. See [https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management](https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management) for details. - ---- - -## AP-10: Monolithic Targets (Too Much in One Target) - -**Smell**: A single `` with 50+ lines doing multiple unrelated things. - -**Why it's bad**: Can't skip individual steps via incremental build, hard to debug, hard to extend, and the target name becomes meaningless. - -```xml - - - - - - - - - - - - - - - - - - - - - - -``` - ---- - -## AP-11: Custom Targets Missing `Inputs` and `Outputs` - -**Smell**: `` with no `Inputs` / `Outputs` attributes. - -**Why it's bad**: The target runs on every build, even when nothing changed. This defeats incremental build and slows down no-op builds. - -See [`references/incremental-build-inputs-outputs.md`](references/incremental-build-inputs-outputs.md) for BAD/GOOD examples and the full pattern including FileWrites registration. - -See `incremental-build` skill for deep guidance on Inputs/Outputs, FileWrites, and up-to-date checks. - ---- - -## AP-12: Setting Defaults in .targets Instead of .props - -**Smell**: `` with default values inside a `.targets` file. - -**Why it's bad**: `.targets` files are imported late (after project files). By the time they set defaults, other `.targets` files may have already used the empty/undefined value. `.props` files are imported early and are the correct place for defaults. - -```xml - - - 2.0 - - - - - - - - - 2.0 - - - - - - -``` - -**Rule**: `.props` = defaults and settings (evaluated early). `.targets` = build logic and targets (evaluated late). - ---- - -## AP-13: Import Without `Exists()` Guard - -**Smell**: `` without a `Condition="Exists('...')"` check. - -**Why it's bad**: If the file doesn't exist (not yet created, wrong path, deleted), the build fails with a confusing error. Optional imports should always be guarded. - -```xml - - - - - - - - -``` - -**Exception — required imports**: Imports that are *required* for the build to work correctly should fail fast — don't guard those. Guard imports that are optional or environment-specific (e.g., local developer overrides, CI-specific settings). - -**Exception — NuGet package forwarders**: `.props`/`.targets` files inside a NuGet package's per-TFM `build/` or `buildTransitive/` folder routinely import a sibling file under `buildTransitive//…` without an `Exists()` guard. These are a **package contract**: the target file is guaranteed to be present in the restored package, even if it doesn't appear in the source tree at that relative path. The package layout is typically produced by: - -- A custom `.nuspec` with per-TFM `` entries — e.g. `` — that copy files from a single source folder (such as `buildTransitive/common/`) into per-TFM subfolders at pack time, or -- `` / `` items in the `.csproj` with a per-TFM `` (e.g. `buildTransitive/net8.0/`), declared once per target TFM, or -- SDK conventions (e.g. `IncludeBuildOutput`, `BuildOutputTargetFolder`) that place built outputs under `build//`. - -Before flagging an unguarded `` inside a `build/` or `buildTransitive/` folder, **resolve it against the packed layout** — read every `*.nuspec` in the project directory **and its immediate parent directory** (shared nuspecs are common in mono-repos; do not walk further up), and any `` metadata on ``/`` items in the `.csproj`. Only flag if the target path is missing from **both** the source tree *and* the projected package layout. The `dotnet-msbuild/extension-points` skill — *Source tree vs packed layout* — documents the full cross-check procedure. - -**Forwarding `buildTransitive/` → `build/`:** forward through the sibling `build/*.props` / `build/*.targets` file (not directly to `buildMultiTargeting/`); when `build/` is per-TFM (`build//`), include the TFM segment derived from the file's own folder (not `$(TargetFramework)`), or transitive consumers hit `MSB4019`. See the `extension-points` skill — *Forwarding chain* — for the rule and derivation expression. - ---- - -## AP-14: Backslashes in Paths — Where It Matters - -**Smell**: Backslash path separators in `.props`/`.targets` files meant to run cross-platform. - -**Where this is a real bug (🔴 Error)** — paths that MSBuild does **not** route through its path normalizer: - -- Raw shell strings inside `` — passed verbatim to `bash`/`sh` on Unix, which treats `\` as an escape. -- Backslash-delimited paths inside CDATA blocks, embedded in source files written by ``, or constructed for non-MSBuild consumers (custom scripts, response files, environment variables). -- Paths handed to custom tasks that call OS file APIs directly without going through MSBuild path utilities. - -**Where this is only a style preference (🔵 Style)** — paths that go through MSBuild's evaluator (``, file-path properties consumed by built-in tasks like ``/``/``, item `Include=`/`Exclude=` globs): - -MSBuild's evaluator normalizes `\` → `/` on Unix-like systems before resolving the path. See `FileUtilities.MaybeAdjustFilePath` and `ConvertToUnixSlashes` in [`microsoft/msbuild` `src/Framework/FileUtilities.cs`](https://github.com/dotnet/msbuild/blob/main/src/Framework/FileUtilities.cs). So `` resolves correctly on Linux/macOS today. Forward slashes are still **preferred for consistency**, but the import will not break and existing backslash-style imports should not be flagged as 🔴 **Error**. - -```xml - - - - - - - - -``` - -**Verification rule**: Before flagging a backslash path as 🔴 **Error**, ask *"does this string flow through MSBuild's evaluator, or is it handed verbatim to a non-MSBuild consumer?"* Only the second case is a correctness defect. - -**Note**: `$(MSBuildThisFileDirectory)` already ends with a platform-appropriate separator, so `$(MSBuildThisFileDirectory)tools/mytool` works on both platforms. - ---- - -## AP-15: Unconditional Property Override in Multiple Scopes - -**Smell**: A property set unconditionally in both `Directory.Build.props` and a `.csproj` — last write wins silently. - -**Why it's bad**: Hard to trace which value is actually used. Makes the build fragile and confusing for anyone reading the project files. - -```xml - - - - bin\custom\ - - - - bin\other\ - - - - - - bin\custom\ - - -``` - ---- - -For additional anti-patterns (AP-16 through AP-23) and a quick-reference checklist, see [additional-antipatterns.md](references/additional-antipatterns.md). diff --git a/.agents/skills/msbuild-antipatterns/references/additional-antipatterns.md b/.agents/skills/msbuild-antipatterns/references/additional-antipatterns.md deleted file mode 100644 index 9ad2e8e..0000000 --- a/.agents/skills/msbuild-antipatterns/references/additional-antipatterns.md +++ /dev/null @@ -1,315 +0,0 @@ -## AP-16: Using `` for String/Path Operations - -**Smell**: `` or `` for simple string manipulation. - -**Why it's bad**: Shell-dependent, not cross-platform, slower than property functions, and the result is hard to capture back into MSBuild properties. - -```xml - - - - - - - - $(Version.Replace('-preview', '')) - $(Version.Contains('-')) - $(AssemblyName.ToLowerInvariant()) - - - - - $([MSBuild]::NormalizeDirectory($(OutputPath))) - $([System.IO.Path]::Combine($(MSBuildThisFileDirectory), 'tools', 'mytool.exe')) - -``` - ---- - -## AP-17: Mixing `Include` and `Update` for the Same Item Type in One ItemGroup - -**Smell**: Same `` has both `` and ``. - -**Why it's bad**: `Update` acts on items already in the set. If `Include` hasn't been processed yet (evaluation order), `Update` may not find the item. Separating them avoids subtle ordering bugs. - -```xml - - - - - - - - - - - - - -``` - ---- - -## AP-18: Redundant `` to Transitively-Referenced Projects - -**Smell**: A project references both `Core` and `Utils`, but `Core` already depends on `Utils`. - -**Why it's bad**: Adds unnecessary coupling, makes the dependency graph harder to understand, and can cause ordering issues in large builds. MSBuild resolves transitive references automatically. - -```xml - - - - - - - - - - -``` - -**Caveat**: If you need to use types from `Utils` directly (not just transitively), the explicit reference is appropriate. But verify whether the direct dependency is actually needed. - ---- - -## AP-19: Side Effects During Property Evaluation - -**Smell**: Property functions that write files, make network calls, or modify state during `` evaluation. - -**Why it's bad**: Property evaluation happens during the evaluation phase, which can run multiple times (e.g., during design-time builds in Visual Studio). Side effects are unpredictable and can corrupt state. - -```xml - - - $([System.IO.File]::WriteAllText('stamp.txt', 'built')) - - - - - - -``` - ---- - -## AP-20: Platform-Specific Exec Without OS Condition - -**Smell**: `` or `` without an OS condition. - -**Why it's bad**: Fails on the wrong platform. If the project is cross-platform, guard platform-specific commands. - -```xml - - - - - - - - - -``` - ---- - -## AP-21: Property Conditioned on TargetFramework in .props Files - -**Smell**: `` or `` in `Directory.Build.props` or any `.props` file imported before the project body. - -**Why it's bad**: `$(TargetFramework)` is NOT reliably available in `Directory.Build.props` or any `.props` file imported before the project body. It is only set that early for multi-targeting projects, which receive `TargetFramework` as a global property from the outer build. Single-targeting projects (using singular ``) set it in the project body, which is evaluated *after* `.props`. This means property conditions on `$(TargetFramework)` in `.props` files silently fail for single-targeting projects — the condition never matches because the property is empty. This applies to both `` and individual `` elements. - -For a detailed explanation of MSBuild's evaluation and execution phases, see [Build process overview](https://learn.microsoft.com/en-us/visualstudio/msbuild/build-process-overview). - -```xml - - - $(DefineConstants);MY_FEATURE - - - - - $(DefineConstants);MY_FEATURE - - - - - $(DefineConstants);MY_FEATURE - - - - - - $(DefineConstants);MY_FEATURE - -``` - -**⚠️ Item and Target conditions are NOT affected.** This restriction applies ONLY to property conditions (`` and ``). Item conditions (``) and Target conditions in `.props` files are SAFE because items and targets evaluate after all properties (including those set in the project body) have been evaluated. This includes `PackageVersion` items in `Directory.Packages.props`, `PackageReference` items in `Directory.Build.props`, and any other item types. - -**Do NOT flag the following patterns — they are correct:** - -```xml - - - - - - - - - - - - - - - - - -``` - ---- - -## AP-22: Forking a Project Instance via `` with Path-Neutral Global Properties - -**Smell**: A target uses the `` task to build or publish a project, passing extra `Properties` that don't change that project's output path. Two common shapes: - -```xml - - - - - -``` - -**Why it's bad**: An MSBuild project instance is identified by its path **plus its global properties**. Passing an extra global property creates a *distinct* instance of the target project — `(project, {_IsPublishing=true})` — that still resolves to the same `OutputPath`/`IntermediateOutputPath` as the instance the solution/graph already builds, `(project, {})`. That project is then built twice, and in a parallel/graph build the two instances can write the same files concurrently (PDBs, `*.sourcelink` and other NativeAOT intermediates, `project.assets.json`), producing `The process cannot access the file because it is being used by another process` or intermittent file-lock failures. This applies whether the offending `` call is in the target project itself or in some other project in the same build. Use the `check-bin-obj-clash` skill to confirm two evaluations of that project differ only by a path-neutral property while sharing an output path. - -```xml - - - - -``` - -```xml - - - - <_PublishWasInvokedDirectly Condition="'$(_IsPublishing)' == 'true'">true - <_IsPublishing>true - - - -``` - -For (a), the static property keeps everything in one instance (one output path, nothing to race); running `Publish` via `DependsOnTargets` (or `CallTarget`) reuses that instance instead of forking. The `_PublishWasInvokedDirectly` guard breaks the target cycle when publish is the entry point (e.g. `dotnet publish`, which sets `_IsPublishing=true` as a global property and would otherwise re-trigger `PublishOnBuild`). - -```xml - - - - - - - - -``` - -For (b), the consumer must not fork the producer with path-neutral global properties. Let the producer publish itself (one instance), reference it only to sequence the build, and read its output. - -**When extra global properties ARE fine**: only when the output path encodes the discriminator (`RuntimeIdentifier`, `TargetFramework`, `Configuration`, `Platform`) so each instance writes to a distinct directory. If you must invoke a project with a path-neutral property, give that build its own `BaseIntermediateOutputPath`/output path so it can't collide. - ---- - -## AP-23: `SetTargetFramework` Metadata on a `ProjectReference` to a Non-Multi-Targeting Project - -**Smell**: A `` carries `SetTargetFramework="TargetFramework=net8.0"` (or similar) metadata, the referenced project is **single-targeting** (uses singular ``, not ``), **and the injected TFM equals the TFM the project already targets**. - -```xml - - - - -``` - -**Why it's bad**: `SetTargetFramework` injects `TargetFramework` as a **global property** on the referenced project's build. That mechanism exists so a consumer can pick *one specific TFM* of a **multi-targeting** project — different TFM values produce different output paths, so each build is distinct and safe. - -For a **single-targeting** project, injecting the TFM it **already targets** is **path-neutral**: the project already resolves to `bin\\net8.0\` and `obj\\net8.0\` on its own, so the extra global property doesn't change the output path — it only creates a *distinct* MSBuild project instance `(project, {TargetFramework=net8.0})`. Meanwhile the solution/graph builds that same project as `(project, {})` with no global properties. Both instances resolve to the **same** `OutputPath`/`IntermediateOutputPath`, so the project is **built twice** and the two instances write the same files (assemblies, PDBs, `project.assets.json`, etc.). Under a parallel build this is a classic bin/obj clash — `The process cannot access the file because it is being used by another process` or intermittent, retry-flaky failures. (Injecting a *different* TFM changes the output path and is a legitimate override — see below.) - -Note the healthy contrast: the P2P protocol itself does **not** inject `TargetFramework` when it sees a non-multi-targeting reference — it correctly omits the global property. `SetTargetFramework` overrides that safe default and is what reintroduces the clash. Use the `check-bin-obj-clash` skill to confirm two evaluations of the referenced project differ only by a path-neutral `TargetFramework` global property while sharing an output path. - -```xml - - - - -``` - -**When `SetTargetFramework` IS appropriate**: - -1. **Multi-targeting reference** — the referenced project is multi-targeting (``) and you deliberately need to consume a specific TFM. Each TFM has its own output path, so the forked instance doesn't collide. - -2. **Deliberately overriding a single-targeting project's TFM to a *different* value** — you can use `SetTargetFramework` on a single-targeting reference to build it under a TFM *other than* the one it declares. This is only valid when the passed-in TFM **differs** from what the project single-targets: because the injected `TargetFramework` then changes the output path (`obj\\\`), the instance no longer collides with the `(project, {})` build. It is **only** the redundant case — passing the *same* TFM the project already targets (path-neutral) — that causes the clash. - -**Related: referencing a framework-incompatible project.** Independently of the clash above, whenever the referencing and referenced projects target **incompatible frameworks** (e.g. a `.NETFramework` project referencing a `.NETCoreApp` project, or vice-versa) — **regardless of whether either side is single- or multi-targeting** — you must set both: -- `SkipGetTargetFrameworkProperties="true"` — bypass the P2P `GetTargetFrameworkProperties` negotiation, which would otherwise fail because the frameworks aren't compatible, and -- `ReferenceOutputAssembly="false"` — because an assembly built for an incompatible framework can't be consumed as a reference; you only want to trigger/sequence the build, not reference its output. - -```xml - - -``` - -**⚠️ Prevent the referencing project's `TargetFramework` from leaking.** When `SkipGetTargetFrameworkProperties="true"` bypasses the negotiation, nothing stops the referencing project's own `TargetFramework` **global property** (present whenever the referencing project is being built for a specific TFM — e.g. it is multi-targeting) from flowing down into the referenced project. If it flows into a **single-targeting** referenced project, that project builds under the *wrong* TFM (and to a different, wrong output path). Guard against it one of two ways: -- set `SetTargetFramework="TargetFramework="` to explicitly pin the referenced build's TFM (also required for multi-targeting references), **or** -- for a single-targeting referenced project you want to build as-declared, set `UndefineProperties="TargetFramework"` to strip the inherited global property so the project uses its own ``. - -```xml - - -``` - -Add `SetTargetFramework` on top of these **only** if you also need to pin the referenced build to a specific TFM (a multi-targeting project, or a single-targeting project you're overriding to a *different* TFM per case 2 above). Use `SetTargetFramework` **or** `UndefineProperties="TargetFramework"`, not both — the former sets the property, the latter removes it. - ---- - -## Quick-Reference Checklist - -When reviewing an MSBuild file, scan for these in order: - -| # | Check | Severity | -|---|-------|----------| -| AP-02 | Unquoted conditions | 🔴 Error-prone | -| AP-19 | Side effects in evaluation | 🔴 Dangerous | -| AP-21 | Property conditioned on TargetFramework in .props | 🔴 Silent failure | -| AP-22 | Forking a project instance via `` with path-neutral global properties (self or cross-project) | 🔴 Race/duplicate build | -| AP-23 | `SetTargetFramework` re-injecting a single-targeting project's own TFM on a `ProjectReference` | 🔴 Race/duplicate build | -| AP-03 | Hardcoded absolute paths | 🔴 Broken on other machines | -| AP-06 | `` with HintPath for NuGet | 🟡 Legacy | -| AP-07 | Missing `PrivateAssets="all"` on tools | 🟡 Leaks to consumers | -| AP-11 | Missing Inputs/Outputs on targets | 🟡 Perf regression | -| AP-13 | Import without Exists guard | 🟡 Fragile | -| AP-05 | Manual file listing in SDK-style | 🔵 Noise | -| AP-04 | Restating SDK defaults | 🔵 Noise | -| AP-08 | Copy-paste across csproj files | 🔵 Maintainability | -| AP-09 | Scattered package versions | 🔵 Version drift | -| AP-01 | `` for built-in tasks | 🔵 Cross-platform | -| AP-14 | Backslashes in cross-platform paths | 🔵 Cross-platform | -| AP-10 | Monolithic targets | 🔵 Maintainability | -| AP-12 | Defaults in .targets instead of .props | 🔵 Ordering issue | -| AP-15 | Unconditional property override | 🔵 Confusing | -| AP-16 | `` for string operations | 🔵 Preference | -| AP-17 | Mixed Include/Update in one ItemGroup | 🔵 Subtle bugs | -| AP-18 | Redundant transitive ProjectReferences | 🔵 Graph noise | -| AP-20 | Platform-specific Exec without guard | 🔵 Cross-platform | diff --git a/.agents/skills/msbuild-antipatterns/references/incremental-build-inputs-outputs.md b/.agents/skills/msbuild-antipatterns/references/incremental-build-inputs-outputs.md deleted file mode 100644 index 7c54447..0000000 --- a/.agents/skills/msbuild-antipatterns/references/incremental-build-inputs-outputs.md +++ /dev/null @@ -1,30 +0,0 @@ -# Incremental Build: Inputs and Outputs on Custom Targets - -Custom targets **must** specify `Inputs` and `Outputs` attributes so MSBuild can skip them when up-to-date. Without both attributes, the target runs on every build. - -```xml - - - - - - - - - - - - - -``` - -**Key points:** -- **`Inputs`** should include `$(MSBuildProjectFile)` plus any source files that drive generation -- **`Outputs`** should use `$(IntermediateOutputPath)` so generated files go in `obj/` and are managed by MSBuild -- **`FileWrites`** registration ensures `dotnet clean` removes the generated file -- **`Compile` inclusion** adds the generated file to compilation without requiring it at evaluation time - -See the `incremental-build` skill for deep guidance on diagnosing broken incremental builds, FileWrites tracking, and Visual Studio's Fast Up-to-Date Check. diff --git a/.agents/skills/msbuild-antipatterns/references/private-assets.md b/.agents/skills/msbuild-antipatterns/references/private-assets.md deleted file mode 100644 index e9414eb..0000000 --- a/.agents/skills/msbuild-antipatterns/references/private-assets.md +++ /dev/null @@ -1,22 +0,0 @@ -# PrivateAssets for Analyzers and Build Tools - -Analyzer and build-tool packages should always use `PrivateAssets="all"` to prevent them from flowing as transitive dependencies to consumers of your library. - -```xml - - - - - - - - - -``` - -**Packages that almost always need `PrivateAssets="all"`:** -- Roslyn analyzers (`*.Analyzers`, `*.CodeFixes`) -- Source generators -- SourceLink packages (`Microsoft.SourceLink.*`) -- Versioning tools (`MinVer`, `Nerdbank.GitVersioning`) -- Build-only tools (`Microsoft.DotNet.ApiCompat`, etc.) diff --git a/.agents/skills/msbuild-modernization/SKILL.md b/.agents/skills/msbuild-modernization/SKILL.md deleted file mode 100644 index d8c52ab..0000000 --- a/.agents/skills/msbuild-modernization/SKILL.md +++ /dev/null @@ -1,501 +0,0 @@ ---- -name: msbuild-modernization -description: "Guide for modernizing and migrating MSBuild project files to SDK-style format. USE FOR: converting legacy .csproj/.vbproj with verbose XML to SDK-style, migrating packages.config to PackageReference, removing Properties/AssemblyInfo.cs in favor of auto-generation, eliminating explicit lists via implicit globbing, consolidating shared settings into Directory.Build.props. Indicators of legacy projects: ToolsVersion attribute, , .csproj files > 50 lines for simple projects. DO NOT USE FOR: projects already in SDK-style format, non-.NET build systems (npm, Maven, CMake), .NET Framework projects that cannot move to SDK-style." -license: MIT ---- - -# MSBuild Modernization: Legacy to SDK-style Migration - -## Identifying Legacy vs SDK-style Projects - -**Legacy indicators:** - -- `` -- Explicit file lists (`` for every `.cs` file) -- `ToolsVersion` attribute on `` element -- `packages.config` file present -- `Properties\AssemblyInfo.cs` with assembly-level attributes - -**SDK-style indicators:** - -- `` attribute on root element -- Minimal content — a simple project may be 10–15 lines -- No explicit file includes (implicit globbing) -- `` items instead of `packages.config` - -**Quick check:** if a `.csproj` is more than 50 lines for a simple class library or console app, it is likely legacy format. - -```xml - - - - - - Debug - AnyCPU - Library - MyLibrary - MyLibrary - v4.7.2 - 512 - true - - - - -``` - -```xml - - - - net472 - - -``` - -## Migration Checklist: Legacy → SDK-style - -### Step 1: Replace Project Root Element - -**BEFORE:** - -```xml - - - - - - -``` - -**AFTER:** - -```xml - - - -``` - -Remove the XML declaration, `ToolsVersion`, `xmlns`, and both `` lines. The `Sdk` attribute replaces all of them. - -### Step 2: Set TargetFramework - -**BEFORE:** - -```xml - - v4.7.2 - -``` - -**AFTER:** - -```xml - - net472 - -``` - -**TFM mapping table:** - -| Legacy `TargetFrameworkVersion` | SDK-style `TargetFramework` | -|---------------------------------|-----------------------------| -| `v4.6.1` | `net461` | -| `v4.7.2` | `net472` | -| `v4.8` | `net48` | -| (migrating to .NET 6) | `net6.0` | -| (migrating to .NET 8) | `net8.0` | - -### Step 3: Remove Explicit File Includes - -**BEFORE:** - -```xml - - - - - - - - - - - - - - -``` - -**AFTER:** - -Delete all of these `` and `` item groups entirely. SDK-style projects include them automatically via implicit globbing. - -**Exception:** keep explicit entries only for files that need special metadata or reside outside the project directory: - -```xml - - - -``` - -### Step 4: Remove AssemblyInfo.cs - -**BEFORE** (`Properties\AssemblyInfo.cs`): - -```csharp -using System.Reflection; -using System.Runtime.InteropServices; - -[assembly: AssemblyTitle("MyLibrary")] -[assembly: AssemblyDescription("A useful library")] -[assembly: AssemblyCompany("Contoso")] -[assembly: AssemblyProduct("MyLibrary")] -[assembly: AssemblyCopyright("Copyright © Contoso 2024")] -[assembly: ComVisible(false)] -[assembly: Guid("...")] -[assembly: AssemblyVersion("1.2.0.0")] -[assembly: AssemblyFileVersion("1.2.0.0")] -``` - -**AFTER** (in `.csproj`): - -```xml - - MyLibrary - A useful library - Contoso - MyLibrary - Copyright © Contoso 2024 - 1.2.0 - -``` - -Delete `Properties\AssemblyInfo.cs` — the SDK auto-generates assembly attributes from these properties. - -**Alternative:** if you prefer to keep `AssemblyInfo.cs`, disable auto-generation: - -```xml - - false - -``` - -### Step 5: Migrate packages.config → PackageReference - -**BEFORE** (`packages.config`): - -```xml - - - - - - -``` - -**AFTER** (in `.csproj`): - -```xml - - - - - -``` - -Delete `packages.config` after migration. - -**Migration options:** - -- **Visual Studio:** right-click `packages.config` → *Migrate packages.config to PackageReference* -- **CLI:** `dotnet migrate-packages-config` or manual conversion -- **Binding redirects:** SDK-style projects auto-generate binding redirects — remove the `` section from `app.config` if present - -### Step 6: Remove Unnecessary Boilerplate - -Delete all of the following — the SDK provides sensible defaults: - -```xml - - - - - - - Debug - AnyCPU - {...} - Library - Properties - 512 - true - true - - - - - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - - - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - - - - - - - - - - - - - - - - - -``` - -**Keep** only properties that differ from SDK defaults (e.g., `Exe`, `` if it differs from the assembly name, custom ``). - -### Step 7: Enable Modern Features - -After migration, consider enabling modern C# features: - -```xml - - net8.0 - enable - enable - -``` - -- `enable` — enables nullable reference type analysis -- `enable` — auto-imports common namespaces (.NET 6+) -- **Avoid `latest`** — the effective language version is determined by the SDK/compiler defaults, not just the TFM, so builds can silently vary across machines with different SDKs installed. Omit `` unless you need to pin a specific version. For reproducible builds, pin the SDK version repo-wide with `global.json` (which indirectly fixes the default language version), or set an explicit numeric `` (e.g. `12`) per project to directly control the language version. - -## Complete Before/After Example - -**BEFORE** (legacy — 65 lines): - -```xml - - - - - Debug - AnyCPU - {12345678-1234-1234-1234-123456789ABC} - Library - Properties - MyLibrary - MyLibrary - v4.7.2 - 512 - true - - - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - - - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - - - - - - - - - - - - - - - - - - - - -``` - -**AFTER** (SDK-style — 11 lines): - -```xml - - - net472 - - - - - - -``` - -## Common Migration Issues - -**Embedded resources:** files not in a standard location may need explicit includes: - -```xml - - - -``` - -**Content files with CopyToOutputDirectory:** these still need explicit entries: - -```xml - - - - -``` - -**Multi-targeting:** change the element name from singular to plural: - -```xml - -net8.0 - - -net472;net8.0 -``` - -**WPF/WinForms projects:** use the appropriate SDK or properties: - -```xml - - - - - - - true - - true - - -``` - -**Test projects:** use the standard SDK with test framework packages: - -```xml - - - net8.0 - false - - - - - - - -``` - -## Central Package Management Migration - -Centralizes NuGet version management across a multi-project solution. See [https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management](https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management) for details. - -**Step 1:** Create `Directory.Packages.props` at the repository root with `true` and `` items for all packages. - -**Step 2:** Remove `Version` from each project's `PackageReference`: - -```xml - - - - - -``` - -## Directory.Build Consolidation - -Identify properties repeated across multiple `.csproj` files and move them to shared files. - -**`Directory.Build.props`** (for properties — placed at repo or src root): - -```xml - - - net8.0 - enable - enable - true - Contoso - Copyright © Contoso 2024 - - -``` - -**`Directory.Build.targets`** (for targets/tasks — placed at repo or src root): - -```xml - - - - - -``` - -**Keep in individual `.csproj` files** only what is project-specific: - -```xml - - - Exe - MyApp - - - - - - -``` - -## Tools and Automation - -| Tool | Usage | -|------|-------| -| `dotnet try-convert` | Automated legacy-to-SDK conversion. Install: `dotnet tool install -g try-convert` | -| .NET Upgrade Assistant | Full migration including API changes. Install: `dotnet tool install -g upgrade-assistant` | -| Visual Studio | Right-click `packages.config` → *Migrate packages.config to PackageReference* | -| Manual migration | Often cleanest for simple projects — follow the checklist above | - -**Recommended approach:** - -1. Run `try-convert` for a first pass -2. Review and clean up the output manually -3. Build and fix any issues -4. Enable modern features (nullable, implicit usings) -5. Consolidate shared settings into `Directory.Build.props` diff --git a/.agents/skills/run-tests/SKILL.md b/.agents/skills/run-tests/SKILL.md deleted file mode 100644 index f1e3226..0000000 --- a/.agents/skills/run-tests/SKILL.md +++ /dev/null @@ -1,288 +0,0 @@ ---- -name: run-tests -description: > - Recommend or run the exact `dotnet test` command. ALWAYS use when the - user asks to run, filter, or troubleshoot .NET tests or wants the precise - command, flags, or argument order — the right syntax depends on the test - platform (VSTest vs Microsoft.Testing.Platform) and SDK version and is - easy to get wrong from memory. USE FOR: running all tests or a subset (a - specific class, category, or trait) via filters; a single framework in a - multi-TFM project (`--framework`); TRX reports; crash or hang dumps; - whether MTP args need the `--` separator (SDK 8/9) or pass directly - (SDK 10+); diagnosing why `dotnet test` fails or uses wrong argument - syntax. Detects the platform (VSTest vs MTP) and framework - (MSTest/xUnit/NUnit/TUnit), then picks the matching command and filter - flag (--filter, --filter-class, --filter-trait, --filter-query, - --treenode-filter). DO NOT USE FOR: writing test code (use - code-testing-agent), iterating on failing tests without rebuilding (use - mtp-hot-reload), CI/CD config, or debugging test logic. -license: MIT ---- - -# Run .NET Tests - -Detect the test platform and framework, run tests, and apply filters using `dotnet test`. - -## When to Use - -- User wants to run tests in a .NET project -- User needs to run a subset of tests using filters -- User needs help detecting which test platform (VSTest vs MTP) or framework is in use -- User wants to understand the correct filter syntax for their setup - -## When Not to Use - -- User needs to write or generate test code (use `writing-mstest-tests` for MSTest, or general coding assistance for other frameworks) -- User needs to migrate from VSTest to MTP (use `migrate-vstest-to-mtp`) -- User wants to iterate on failing tests without rebuilding (use `mtp-hot-reload`) -- User needs CI/CD pipeline configuration (use CI-specific skills) -- User needs to debug a test (use debugging skills) - -## Inputs - -| Input | Required | Description | -|-------|----------|-------------| -| Project or solution path | No | Path to the test project (.csproj) or solution (.sln, .slnf, .slnx). Defaults to current directory. | -| Filter expression | No | Filter expression to select specific tests | -| Target framework | No | Target framework moniker to run against (e.g., `net8.0`) | - -## Critical Rules — Avoid Cross-Platform Mistakes - -These are the most common agent mistakes. Internalize before proceeding: - -| Rule | Why | -|------|-----| -| **Do NOT use `--logger trx`** for MTP projects | MTP uses `--report-trx` (requires the TrxReport extension package) | -| **Do NOT use `--report-trx`** for VSTest projects | VSTest uses `--logger trx` | -| **Do NOT use `-- --arg`** on .NET SDK 10+ | SDK 10+ passes MTP args directly: `dotnet test --project . --report-trx` | -| **Do NOT omit `--`** on .NET SDK 8/9 with MTP | SDK 8/9 requires the separator: `dotnet test -- --report-trx` | -| **Do NOT use `--filter "ClassName=..."`** with xUnit v3 on MTP | xUnit v3 on MTP uses `--filter-class`, `--filter-method`, `--filter-trait` | -| **Do NOT use bare positional path** on SDK 10+ | Use `--project ` or `--solution ` instead | -| **Do NOT use `--blame`** for MTP projects | MTP uses `--blame-crash` and `--blame-hang-timeout` separately (each requires its extension package) | -| **Do NOT use `--collect "Code Coverage"`** for MTP | MTP uses `--coverage` (requires the CodeCoverage extension package) | - -## Workflow - -### Quick Reference - -| Platform | SDK | Command pattern | -|----------|-----|----------------| -| VSTest | Any | `dotnet test [] [--filter ] [--logger trx]` | -| MTP | 8 or 9 | `dotnet test [] -- ` | -| MTP | 10+ | `dotnet test --project ` | - -**Detection files to always check** (in order): `global.json` -> `.csproj` -> `Directory.Build.props` -> `Directory.Packages.props` - -**If the prompt names a subset of tests** (e.g., "integration tests", "smoke tests", a specific class, a specific TFM), plan to apply the matching filter / `--framework` in [Step 3](#step-3-run-filtered-tests) — do not run the whole suite. - -### Step 1: Detect the test platform and framework - -1. Run `dotnet --version` in the project directory to determine the SDK version. This accounts for `global.json` SDK pinning. -2. Read `global.json` — on .NET SDK 10+, `"test": { "runner": "Microsoft.Testing.Platform" }` is the **authoritative MTP signal**. If present, the project uses MTP and SDK 10+ syntax (no `--` separator). -3. Read `.csproj`, `Directory.Build.props`, **and** `Directory.Packages.props` for framework packages and MTP properties. **Always check all three files** — MTP properties are frequently set in `Directory.Build.props` rather than individual `.csproj` files. -4. For full detection logic (SDK 8/9 signals, framework identification), see the `platform-detection` skill. - -**What to look for in each file:** - -| File | Look for | Indicates | -|------|----------|-----------| -| `global.json` | `"test": { "runner": "Microsoft.Testing.Platform" }` | MTP on SDK 10+ | -| `global.json` | `"sdk": { "version": "..." }` | SDK version (determines `--` separator behavior) | -| `.csproj` | `true` | MTP on SDK 8/9 | -| `.csproj` | `MSTest`, `xunit.v3`, `NUnit`, `TUnit` packages | Framework identity | -| `.csproj` | `Microsoft.NET.Test.Sdk` + test adapter | VSTest (unless overridden by MTP signals above) | -| `.csproj` | `` (plural) | Multi-TFM — may need `--framework` | -| `Directory.Build.props` | `true` | MTP on SDK 8/9 (often set here, not in .csproj) | -| `Directory.Packages.props` | Centrally managed test package versions | Framework identity for CPM repos | - -**Quick detection summary:** - -| Signal | Means | -|--------|-------| -| `global.json` has `"test": { "runner": "Microsoft.Testing.Platform" }` | **MTP on SDK 10+** — pass args directly, no `--` | -| `true` in csproj or Directory.Build.props | **MTP on SDK 8/9** — pass args after `--` | -| Neither signal present | **VSTest** | - -### Step 2: Run tests - -#### VSTest (any .NET SDK version) - -```bash -dotnet test [ | | | | ] -``` - -Common flags: - -| Flag | Description | -|------|-------------| -| `--framework ` | Target a specific framework in multi-TFM projects (e.g., `net8.0`) | -| `--no-build` | Skip build, use previously built output | -| `--filter ` | Run selected tests (see [Step 3](#step-3-run-filtered-tests)) | -| `--logger trx` | Generate TRX results file | -| `--collect "Code Coverage"` | Collect code coverage using Microsoft Code Coverage (built-in, always available) | -| `--blame` | Enable blame mode to detect tests that crash the host | -| `--blame-crash` | Collect a crash dump when the test host crashes | -| `--blame-hang-timeout ` | Abort test if it hangs longer than duration (e.g., `5min`) | -| `-v ` | Verbosity: `quiet`, `minimal`, `normal`, `detailed`, `diagnostic` | - -#### MTP with .NET SDK 8 or 9 - -With `true`, `dotnet test` bridges to MTP but uses VSTest-style argument parsing. MTP-specific arguments must be passed after `--`: - -```bash -dotnet test [ | | | | ] -- -``` - -#### MTP with .NET SDK 10+ - -With the `global.json` runner set to `Microsoft.Testing.Platform`, `dotnet test` natively understands MTP arguments without `--`: - -```bash -dotnet test - [--project ] - [--solution ] - [--test-modules ] - [] -``` - -Examples: - -```bash -# Run all tests in a project -dotnet test --project path/to/MyTests.csproj - -# Run all tests in a directory containing a project -dotnet test --project path/to/ - -# Run all tests in a solution (sln, slnf, slnx) -dotnet test --solution path/to/MySolution.sln -dotnet test --solution path/to/MySolution.slnf -dotnet test --solution path/to/MySolution.slnx - -# Run all tests in a directory containing a solution -dotnet test --solution path/to/ - -# Run with MTP flags -dotnet test --project path/to/MyTests.csproj --report-trx --blame-hang-timeout 5min -``` - -> **Note**: The .NET 10+ `dotnet test` syntax does **not** accept a bare positional argument like the VSTest syntax. Use `--project`, `--solution`, or `--test-modules` to specify the target. - -#### Common MTP flags - -These flags apply to MTP on both SDK versions. On SDK 8/9, pass after `--`; on SDK 10+, pass directly. - -> **Important:** `dotnet test`/MSBuild flags such as `--framework`, `--no-build`, `--configuration`, and `--verbosity` are consumed by `dotnet test` itself (they drive restore/build/host selection) and **always go BEFORE `--`**, regardless of platform or SDK. Only MTP test-platform arguments go after `--` on SDK 8/9. For example: `dotnet test --framework net9.0 -- --report-trx` (built-in flag before `--`, MTP extension flag after). - -**Built-in flags (always available):** - -| Flag | Description | -|------|-------------| -| `--results-directory ` | Directory for test result output | -| `--diagnostic` | Enable diagnostic logging for the test platform | -| `--diagnostic-output-directory ` | Directory for diagnostic log output | - -**Extension-dependent flags (require the corresponding extension package to be registered):** - -| Flag | Requires | Description | -|------|----------|-------------| -| `--filter ` | Framework-specific (not all frameworks support this) | Run selected tests (see [Step 3](#step-3-run-filtered-tests)) | -| `--report-trx` | `Microsoft.Testing.Extensions.TrxReport` | Generate TRX results file | -| `--report-trx-filename ` | `Microsoft.Testing.Extensions.TrxReport` | Set TRX output filename | -| `--blame-hang-timeout ` | `Microsoft.Testing.Extensions.HangDump` | Abort test if it hangs longer than duration (e.g., `5min`) | -| `--blame-crash` | `Microsoft.Testing.Extensions.CrashDump` | Collect a crash dump when the test host crashes | -| `--coverage` | `Microsoft.Testing.Extensions.CodeCoverage` | Collect code coverage using Microsoft Code Coverage | - -> Some frameworks (e.g., MSTest) bundle common extensions by default. Others may require explicit package references. If a flag is not recognized, check that the corresponding extension package is referenced in the project. - -#### Alternative MTP invocations - -MTP test projects are standalone executables. Beyond `dotnet test`, they can be run directly: - -```bash -# Build and run -dotnet run --project - -# Run a previously built DLL -dotnet exec - -# Run the executable directly (Windows) - -``` - -These alternative invocations accept MTP command line arguments directly (no `--` separator needed). - -### Step 3: Run filtered tests - -See the `filter-syntax` skill for the complete filter syntax for each platform and framework combination. Key points: - -- **VSTest** (MSTest, xUnit v2, NUnit): `dotnet test --filter ` with `=`, `!=`, `~`, `!~` operators -- **MTP -- MSTest and NUnit**: Same `--filter` syntax as VSTest; pass after `--` on SDK 8/9, directly on SDK 10+ -- **MTP -- xUnit v3**: Uses `--filter-class`, `--filter-method`, `--filter-trait` (not VSTest expression syntax). For a **single combined expression** (e.g., a class-name pattern AND a trait), use `--filter-query` with the xUnit v3 query filter language: path segments `////` with `*` wildcards and a `[Trait=Value]` qualifier — for example `dotnet test -- --filter-query "/*/*/*IntegrationTests*/*[Category=Smoke]"`. See the `filter-syntax` skill for the full query language. -- **MTP -- TUnit**: Uses `--treenode-filter` with path-based syntax - -#### When the user names a test category, trait, or group - -When the prompt names a subset of tests by category (e.g., "integration tests", "unit tests", "smoke tests", "fast tests"), **do not run all tests** — translate the user's vocabulary into the platform-appropriate filter: - -1. **Inspect the test source files** for filter-attribute annotations that match the named group: - - | Framework | Attribute | Filter property | - |-----------|-----------|-----------------| - | MSTest | `[TestCategory("Integration")]` | `TestCategory` | - | NUnit | `[Category("Integration")]` | `TestCategory` (mapped) | - | xUnit v2 | `[Trait("Category", "Integration")]` | `Category` | - | xUnit v3 | `[Trait("Category", "Integration")]` | `Category` (use `--filter-trait`) | - | TUnit | `[Category("Integration")]` | `Category` | - -2. **Build the filter expression** and combine it with the platform-correct invocation. For "run the integration tests" against an MSTest project: - - | Platform | SDK | Command | - |----------|-----|---------| - | VSTest (MSTest) | any | `dotnet test --filter "TestCategory=Integration"` | - | MTP (MSTest) | 8 or 9 | `dotnet test -- --filter "TestCategory=Integration"` | - | MTP (MSTest) | 10+ | `dotnet test --filter "TestCategory=Integration"` | - | MTP (xUnit v3) | 8 or 9 | `dotnet test -- --filter-trait "Category=Integration"` | - | MTP (xUnit v3) | 10+ | `dotnet test --filter-trait "Category=Integration"` | - | MTP (TUnit) | 8 or 9 | `dotnet test -- --treenode-filter "/*/*/*/*[Category=Integration]"` | - -3. If you cannot find a matching attribute, ask the user to confirm the category name or fall back to a name-pattern filter (e.g., `--filter "FullyQualifiedName~Integration"`). - -## Validation - -- [ ] Test platform (VSTest or MTP) was correctly identified -- [ ] Test framework (MSTest, xUnit, NUnit, TUnit) was correctly identified -- [ ] Correct `dotnet test` invocation was used for the detected platform and SDK version -- [ ] When the user named a test category/trait/group, the appropriate filter was applied (not "run all tests") -- [ ] Filter expressions used the syntax appropriate for the platform and framework -- [ ] Test results were clearly reported to the user - -## Common Pitfalls - -| Pitfall | Solution | -|---------|----------| -| Missing `Microsoft.NET.Test.Sdk` in a VSTest project | Tests won't be discovered. Add `` | -| Using VSTest `--filter` syntax with xUnit v3 on MTP | xUnit v3 on MTP uses `--filter-class`, `--filter-method`, etc. -- not the VSTest expression syntax | -| Passing MTP args without `--` on .NET SDK 8/9 | Before .NET 10, MTP args must go after `--`: `dotnet test -- --report-trx` | -| Using `-- --arg` separator on .NET SDK 10+ | SDK 10+ passes MTP args directly — do NOT use `--` separator | -| Using `--logger trx` for MTP or `--report-trx` for VSTest | Each platform has its own TRX flag — check the Critical Rules table | -| Only checking `.csproj` for MTP signals | Always check `Directory.Build.props` and `Directory.Packages.props` too — MTP properties are frequently set there | -| Using bare positional path argument on SDK 10+ | SDK 10+ requires named flags: `--project ` or `--solution ` | - -## Troubleshooting - -Common error messages and how to resolve them: - -| Error | Cause | Fix | -|-------|-------|-----| -| `No test is available` or `No test matches the given testcase filter` | Wrong filter syntax for the platform/framework, or tests not discovered | Verify filter syntax matches the platform (see `filter-syntax` skill). For discovery issues, check that the test SDK and adapter packages are installed | -| `The --report-trx option is unrecognized` | MTP extension package not referenced, or using MTP flag on a VSTest project | Add `` for MTP, or use `--logger trx` for VSTest | -| `The --blame-hang-timeout option is unrecognized` | Missing HangDump extension on MTP | Add `` | -| `error NETSDK1045: The current .NET SDK does not support targeting .NET X.0` | SDK version in `global.json` doesn't match the project's target framework | Update `global.json` SDK version or install the required SDK | -| `The test runner process exited with non-zero exit code` | MTP test host crashed or test failure | Run with `--blame-crash` (MTP) or `--blame` (VSTest) to collect a crash dump for diagnosis | -| `No test source files were found` / `No test project found` | `dotnet test` can't find a test project in the given path | Specify the path explicitly: `dotnet test ` (VSTest) or `dotnet test --project ` (SDK 10+) | -| Tests discovered but 0 executed | Filter expression matches no tests | Double-check filter property names and values. Common typo: `TestCategory` (MSTest) vs `Category` (NUnit) vs trait syntax (xUnit) | -| Using `--` for MTP args on .NET SDK 10+ | On .NET 10+, MTP args are passed directly: `dotnet test --project . --blame-hang-timeout 5min` — do NOT use `-- --blame-hang-timeout` | -| Multi-TFM project runs tests for all frameworks | Use `--framework ` to target a specific framework | -| `global.json` runner setting ignored | Requires .NET 10+ SDK. On older SDKs, use `` MSBuild property instead | -| TUnit `--treenode-filter` not recognized | TUnit is MTP-only. On .NET SDK 10+ use `dotnet test`; on older SDKs use `dotnet run` since VSTest-mode `dotnet test` does not support TUnit | diff --git a/.agents/skills/test-anti-patterns/SKILL.md b/.agents/skills/test-anti-patterns/SKILL.md deleted file mode 100644 index cdcabca..0000000 --- a/.agents/skills/test-anti-patterns/SKILL.md +++ /dev/null @@ -1,173 +0,0 @@ ---- -name: test-anti-patterns -description: > - Audits an existing test file or suite in any language for anti-patterns - and quality issues — produces a severity-ranked report - (Critical/Warning/Info). INVOKE whenever asked to audit or review tests, - find what's wrong with a suite, judge whether tests are any good, or - check for: tests that pass but verify nothing, missing assertions, - swallowed exceptions, self-comparing / tautological assertions, - coverage-touching tests, broad exceptions, flaky or order-dependent tests - (Thread.Sleep, DateTime.Now, shared state), duplicated tests, or magic - values — in .NET, Python/pytest, TS/Jest, Java, Go, Ruby or C++. DO NOT - USE FOR: writing new tests (use code-testing-agent, or writing-mstest-tests - for MSTest); running tests (use - run-tests); migration; assertion-diversity metrics (use assertion-quality); - coverage/CRAP metrics (use coverage-analysis); the testsmells.org academic - catalog (use test-smell-detection); fixing or modernizing MSTest tests, - assertions, attributes, or lifecycle (use writing-mstest-tests). -license: MIT ---- - -# Test Anti-Pattern Detection - -Quick, pragmatic analysis of test code in any supported language for anti-patterns and quality issues that undermine test reliability, maintainability, and diagnostic value. - -> **Language-specific guidance**: Call the `test-analysis-extensions` skill to discover available extension files, then read the file matching the target codebase (e.g., `extensions/dotnet.md`, `extensions/python.md`, `extensions/typescript.md`, `extensions/go.md`). The extension file tells you which sleep / time / random / skip / setup-teardown / mystery-guest APIs to look for in that language. - -## When to Use - -- User asks to review test quality or find test smells -- User wants to know why tests are flaky or unreliable -- User asks "are my tests good?" or "what's wrong with my tests?" -- User requests a test audit or test code review -- User wants to improve existing test code - -## When Not to Use - -- User wants to write new tests from scratch (use `code-testing-agent` for any language, or `writing-mstest-tests` for MSTest specifically) -- User wants direct implementation fixes rather than a diagnostic review (use the relevant write/edit skill) -- User asks to fix swapped `Assert.AreEqual` argument order in MSTest (use `writing-mstest-tests`) -- User asks to convert MSTest `DynamicData` from `IEnumerable` to `ValueTuple` (use `writing-mstest-tests`) -- User wants to run or execute tests (use `run-tests` for .NET) -- User wants to migrate between test frameworks or versions (use migration skills) -- User wants to measure code coverage (out of scope) -- User wants a deep formal test smell audit with academic taxonomy and extended catalog (use `test-smell-detection`) - -## Inputs - -| Input | Required | Description | -|-------|----------|-------------| -| Test code | Yes | One or more test files or classes to analyze | -| Production code | No | The code under test, for context on what tests should verify | -| Specific concern | No | A focused area like "flakiness" or "naming" to narrow the review | - -## Workflow - -### Step 1: Detect language and load extension - -Identify the target codebase's language and test framework. Call the `test-analysis-extensions` skill and read the matching extension file. The extension file documents framework-specific anti-pattern markers — what counts as a sleep/wait, a test marker, a skip, a setup/teardown, a shared-state hot spot, and an integration boundary — so this skill stays language-neutral. - -### Step 2: Gather the test code - -Read the test files the user wants reviewed. If the user points to a directory or project, scan for all test files using the discovery markers in the loaded language extension file (e.g., `[TestClass]`/`[Fact]`/`[Test]` for .NET, `test_*.py` / `def test_*` for pytest, `*.test.ts` / `it()` for Jest, `*Test.java` / `@Test` for JUnit, `*_test.go` / `func TestXxx` for Go, `*_spec.rb` for RSpec, `#[test]` for Rust, `*.Tests.ps1` / `Describe` for Pester, `TEST(...)` for GoogleTest, `TEST_CASE(...)` for Catch2/doctest). - -If production code is available, read it too -- this is critical for detecting tests that are coupled to implementation details rather than behavior. - -### Step 3: Scan for anti-patterns - -Check each test file against the anti-pattern catalog below. Report findings grouped by severity. The examples are .NET-centric but the patterns generalize — use the loaded language extension file to map each pattern to the framework you are auditing. - -#### Critical -- Tests that give false confidence - -| Anti-Pattern | What to Look For | -|---|---| -| **No assertions** | Test methods that execute code but never assert anything. A passing test without assertions proves nothing. In .NET look for missing `Assert.*`; in pytest a function with no `assert` and no `pytest.raises`; in Jest no `expect(...)`; in JUnit no `assert*`/`assertThat`; in Go a test that never calls `t.Error*`, `t.Fatal*`, or testify; in RSpec a block with no `expect`; in Pester no `Should`. Mock-call verifications (`verify(mock)`, `expect(mock).toHaveBeenCalled`, `Should -Invoke`) are real assertions. | -| **Missing await on async assertions (JS/TS, .NET, Python, Kotlin, Swift)** | `expect(promise).resolves.toBe(x)` without `await`/`return`, `pytest-asyncio` test with un-awaited coroutine, `async Task` xUnit test calling `Assert.ThrowsAsync` without `await`, Kotest suspending test without `runTest`, Swift Testing async test without `await`. These tests silently pass even when the underlying assertion would have failed. | -| **Coverage touching** | Test class that methodically calls every public member on a type — often in alphabetical or declaration order — without asserting meaningful outcomes. Each test typically does `var result = sut.MethodName(...)` (or `result = sut.method_name(...)`, `sut.methodName()`, `sut.MethodName(t)`) with no assertion, or only a trivial null/None/nil check. The intent is to inflate code-coverage metrics rather than verify behavior. Distinct from a single assertion-free test: the pattern is *systematic* coverage of the surface area with no real verification. | -| **Self-referential assertion** | Asserts that the output of an operation equals its input when the operation is expected to be an identity or no-op, e.g. `Assert.AreEqual(input, Parse(input.ToString()))`, `assert input == parse(str(input))`, `expect(parse(input.toString())).toBe(input)`, `assert.Equal(t, input, parse(input))`. Also flags `Assert.AreEqual(dto.Name, dto.Name)` / `assert dto.name == dto.name` / `expect(dto.name).toBe(dto.name)` (asserting a field against itself). The test is tautological — it can only fail if the round-trip is broken, but never verifies that a *transformation* actually happened. | -| **Swallowed exceptions** | `try { ... } catch { }`, `catch (Exception)` without rethrowing or asserting (.NET); bare `except:` or `except Exception:` with `pass` (Python); `try { ... } catch (e) {}` (JS/TS/Java); `defer recover()` without re-panic and no assertion (Go); `rescue StandardError` with no assertion (Ruby); `Result::unwrap_or(...)` swallowing errors in a test (Rust); empty `catch` block (Kotlin/Swift). | -| **Assert in catch block only** | `try { Act(); } catch (Exception ex) { Assert.Fail(ex.Message); }` (and equivalents in other languages) -- use `Assert.ThrowsException` / `pytest.raises` / `expect(fn).toThrow` / `assertThrows` / `assert.Error(t, err)` / `#[should_panic]` / `Should -Throw` / `EXPECT_THROW` instead. The test passes when no exception is thrown even if the result is wrong. | -| **Always-true assertions** | `Assert.IsTrue(true)`, `Assert.AreEqual(x, x)`, `assert True`, `expect(true).toBe(true)`, `assert.True(t, true)`, `assert!(true)`, or conditions that can never fail. | -| **Commented-out assertions** | Assertions that were disabled but the test still runs, giving the illusion of coverage. | - -#### High -- Tests likely to cause pain - -| Anti-Pattern | What to Look For | -|---|---| -| **Flakiness indicators** | Wall-clock sleeps/waits used for synchronization: `Thread.Sleep` / `Task.Delay` (.NET), `time.sleep` (Python), `setTimeout` / `await new Promise(r => setTimeout(...))` (JS/TS), `Thread.sleep` (Java/Kotlin), `time.Sleep` (Go), `sleep` (Ruby/Bash), `std::thread::sleep` (Rust), `Start-Sleep` (Pester), `std::this_thread::sleep_for` (C++). Wall-clock reads without abstraction: `DateTime.Now`/`UtcNow`, `datetime.now()`/`datetime.utcnow()`, `Date.now()` / `new Date()`, `System.currentTimeMillis()`, `time.Now()`, `Time.now`, `Instant::now()`, `Date()`/`Date.now`, `Get-Date`, `std::chrono::system_clock::now`. Unseeded randomness: `new Random()`, `random.random()`/`random.randint()`, `Math.random()`, `new Random()` (Java/Kotlin), `rand.Int()` without seed, `rand` (Ruby), `rand::random()` (Rust). Environment-dependent paths (hard-coded `C:\...`, `/tmp/...`, network hosts). | -| **Test ordering dependency** | Static/global mutable state modified across tests; setup that doesn't fully reset state (`[TestInitialize]`, `setUp`, `beforeEach`, `before(:each)`, `BeforeEach`, `t.Cleanup`); tests that fail when run individually but pass in suite (or vice versa). Examples per language: `static` fields (.NET/Java), module-level globals (Python), top-level `let`/`const` in test file (JS/TS), `var` package globals (Go), class variables (Ruby), `static mut`/`lazy_static!`/`OnceCell` (Rust), `$script:` variables (PowerShell). | -| **Over-mocking** | More mock setup lines than actual test logic. Verifying exact call sequences on mocks rather than outcomes. Mocking types the test owns. Per language: Moq/NSubstitute/FakeItEasy (.NET), `unittest.mock` / `pytest-mock` (Python), Jest auto-mocks / Sinon (JS/TS), Mockito/PowerMock (Java), gomock/testify mock (Go), RSpec mocks/mocha (Ruby), `mockall` (Rust), MockK (Kotlin), `Mock` cmdlet (Pester), gmock (C++). For a deep mock audit in .NET, use `exp-mock-usage-analysis`. | -| **Implementation coupling** | Testing private methods via reflection (`MethodInfo.Invoke`, `getattr` in Python, `(thing as any)` in TS, `Field.setAccessible(true)` in Java, `Object#send` in Ruby, internal `pub(crate)` access in Rust). Asserting on internal state instead of observable behavior. Verifying exact method call counts on collaborators instead of business outcomes. | -| **Broad exception assertions** | `Assert.ThrowsException(...)` (.NET) / `pytest.raises(Exception)` / `expect(fn).toThrow(Error)` without a message matcher / `assertThrows(Exception.class, ...)` (Java) / `assert.Error(t, err)` without checking the kind / `expect { ... }.to raise_error` without class (RSpec) / `#[should_panic]` without `expected = "..."` / `Should -Throw` without `-ExpectedMessage` / `EXPECT_ANY_THROW` instead of `EXPECT_THROW(stmt, SpecificType)`. | - -#### Medium -- Maintainability and clarity issues - -| Anti-Pattern | What to Look For | -|---|---| -| **Poor naming** | Test names like `Test1`, `TestMethod`, `test`, names that don't describe the scenario or expected outcome. Good naming differs by language convention — see the loaded language extension file (e.g., `Add_NegativeNumber_ThrowsArgumentException` for .NET, `test_add_negative_number_raises_value_error` for pytest, `addNegativeNumber_throwsArgumentException` for Java, `'adds negative number throws'` for Jest descriptions, `TestAdd_NegativeNumber_ReturnsError` for Go). | -| **Magic values** | Unexplained numbers or strings in arrange/assert: `Assert.AreEqual(42, result)` / `assert result == 42` / `expect(result).toBe(42)` -- what does 42 mean? | -| **Duplicate tests** | Three or more test methods with near-identical bodies that differ only in a single input value. Should be parametrized: `[DataRow]`/`[Theory]`/`[TestCase]` (.NET), `@pytest.mark.parametrize` (pytest), `test.each` / `it.each` (Jest/Vitest), `@ParameterizedTest` + `@ValueSource` (JUnit 5), `@DataProvider` (TestNG), Go table-driven tests, `where` / shared examples (RSpec), `#[rstest]` (Rust), `@ParameterizedTest` + `@MethodSource` (Kotlin), `-ForEach` / `-TestCases` (Pester), `INSTANTIATE_TEST_SUITE_P` (GoogleTest), `SECTION` / `GENERATE` (Catch2), `TEST_CASE_TEMPLATE` (doctest). For a detailed duplication analysis in .NET, use `exp-test-maintainability`. Note: Two tests covering distinct boundary conditions (e.g., zero vs. negative) are NOT duplicates -- separate tests for different edge cases provide clearer failure diagnostics and are a valid practice. | -| **Giant tests** | Test methods exceeding ~30 lines or testing multiple behaviors at once. Hard to diagnose when they fail. | -| **Assertion messages that repeat the assertion** | `Assert.AreEqual(expected, actual, "Expected and actual are not equal")` / `assert x == y, "x is not equal to y"` / `assertEquals(x, y, "values not equal")` add no information. Messages should describe the business meaning. | -| **Missing AAA / Given-When-Then separation** | Arrange/Act/Assert (or Given/When/Then for BDD frameworks like RSpec, Kotest behavior specs, Pester) phases are interleaved or indistinguishable. | - -#### Low -- Style and hygiene - -| Anti-Pattern | What to Look For | -|---|---| -| **Unused test infrastructure** | Setup/teardown hooks that do nothing — `[TestInitialize]`/`[SetUp]`/`[BeforeEach]`, `setUp`/`@BeforeEach`/`@BeforeAll`, `beforeEach`/`beforeAll`, `before(:each)`/`before(:all)`, `BeforeEach`/`BeforeAll` (Pester), `setUpWithError` (XCTest) — and test helper methods that are never called. | -| **Unmanaged resources** | Test creates disposable/closeable resources without cleanup: `HttpClient`/`Stream` without `using` (.NET), file/connection without `with` block or `try/finally` (Python), `FileInputStream` without `try-with-resources` (Java), `defer file.Close()` missing (Go), connection without `ensure` (Ruby), `Drop` not relied on / forgotten `close` (Rust), missing teardown for temp files / DBs in any language. | -| **Print debugging** | Leftover `Console.WriteLine` / `Debug.WriteLine` / `print()` / `console.log` / `System.out.println` / `fmt.Println` / `puts` / `dbg!` / `Write-Host` / `std::cout` statements used during test development. | -| **Inconsistent naming convention** | Mix of naming styles in the same test class/module/file (e.g., some use `Method_Scenario_Expected`, others use `ShouldDoSomething`). | - -### Step 4: Calibrate severity honestly - -Before reporting, re-check each finding against these severity rules: - -- **Critical/High**: Only for issues that cause tests to give false confidence or be unreliable. A test that always passes regardless of correctness is Critical. Flaky shared state is High. Missing-await on async assertions is Critical (silent pass). -- **Medium**: Only for issues that actively harm maintainability -- 5+ nearly-identical tests, truly meaningless names like `Test1` / `test` / `it1`. -- **Low**: Cosmetic naming mismatches, minor style preferences, assertion messages that could be better. When in doubt, rate Low. -- **Not an issue** (per-language nuance): - - Go and Rust **table-driven loops** with sub-tests (`t.Run` / `for case in cases { ... }`) are *idiomatic*, not "Conditional Test Logic". Do NOT flag. - - pytest **bare `assert`** is the canonical assertion form, not a missing assertion library. Do NOT flag. - - Go tests use `if got != want { t.Errorf(...) }` as canonical equality. Do NOT flag as ad-hoc. - - Separate tests for distinct boundary conditions (zero vs. negative vs. null). Do NOT flag as duplicates. - - Explicit per-test setup instead of `[TestInitialize]` / `beforeEach` (this *improves* isolation). - - Tests that are short and clear but could theoretically be consolidated. - -IMPORTANT: If the tests are well-written, say so clearly up front. Do not inflate severity to justify the review. A review that finds zero Critical/High issues and only minor Low suggestions is a valid and valuable outcome. Lead with what the tests do well. - -### Step 5: Report findings - -Present findings in this structure: - -1. **Summary** -- Total issues found, broken down by severity (Critical / High / Medium / Low). If tests are well-written, lead with that assessment. -2. **Critical and High findings** -- List each with: - - The anti-pattern name - - The specific location (file, method name, line) - - A brief explanation of why it's a problem - - A concrete fix (show before/after code when helpful) -3. **Medium and Low findings** -- Summarize in a table unless the user wants full detail -4. **Positive observations** -- Call out things the tests do well (sealed class, specific exception types, data-driven tests, clear AAA structure, proper use of fakes, good naming). Don't only report negatives. - -### Step 6: Prioritize recommendations - -If there are many findings, recommend which to fix first: - -1. **Critical** -- Fix immediately, these tests may be giving false confidence -2. **High** -- Fix soon, these cause flakiness or maintenance burden -3. **Medium/Low** -- Fix opportunistically during related edits - -## Validation - -- [ ] Every finding includes a specific location (not just a general warning) -- [ ] Every Critical/High finding includes a concrete fix -- [ ] Report covers all categories (assertions, isolation, naming, structure) -- [ ] Positive observations are included alongside problems -- [ ] Recommendations are prioritized by severity - -## Common Pitfalls - -| Pitfall | Solution | -|---------|----------| -| Reporting style issues as critical | Naming and formatting are Medium/Low, never Critical | -| Suggesting rewrites instead of targeted fixes | Show minimal diffs -- change the assertion, not the whole test | -| Flagging intentional design choices | If `Thread.Sleep` / `time.sleep` / `time.Sleep` is in an integration test testing actual timing, that's not an anti-pattern. Consider context. | -| Inventing false positives on clean code | If tests follow best practices, say so. A review finding "0 Critical, 0 High, 1 Low" is perfectly valid. Don't inflate findings to justify the review. | -| Flagging separate boundary tests as duplicates | Two tests for zero and negative inputs test different edge cases. Only flag as duplicates when 3+ tests have truly identical bodies differing by a single value. | -| Rating cosmetic issues as Medium | Naming mismatches (e.g., method name says `ArgumentException` but asserts `ArgumentOutOfRangeException`) are Low, not Medium -- the test still works correctly. | -| Ignoring the test framework | Use correct terminology per the loaded language extension: xUnit `[Fact]`/`[Theory]`, NUnit `[Test]`/`[TestCase]`, MSTest `[TestMethod]`/`[DataRow]`, pytest `def test_*` / `@pytest.mark.parametrize`, Jest `it.each` / `describe`, JUnit `@Test` / `@ParameterizedTest`, Go `func TestXxx(t *testing.T)` + table-driven, RSpec `describe`/`it`, Pester `Describe`/`It`, Rust `#[test]` / `#[rstest]`, Catch2 `TEST_CASE`/`SECTION`. | -| Treating idiomatic patterns as smells | Go/Rust **table-driven loops** are idiomatic. Pytest **bare `assert`** is canonical. Go's `if got != want { t.Errorf(...) }` is canonical. JS/TS `expect(mock).toHaveBeenCalledWith(...)` is a real assertion, not an over-mock. Do NOT flag these. | -| Missing async-test pitfalls | A Jest test that calls `expect(promise).resolves.toBe(x)` without returning/awaiting the promise silently passes; a TUnit/xUnit `async Task` test calling `Assert.ThrowsAsync` without `await` silently passes; pytest-asyncio tests with un-awaited coroutines silently pass. Always flag as Critical. | -| Missing the forest for the trees | If 80% of tests have no assertions, lead with that systemic issue rather than listing every instance | diff --git a/.agents/skills/test-gap-analysis/SKILL.md b/.agents/skills/test-gap-analysis/SKILL.md deleted file mode 100644 index 1879675..0000000 --- a/.agents/skills/test-gap-analysis/SKILL.md +++ /dev/null @@ -1,220 +0,0 @@ ---- -name: test-gap-analysis -description: "Performs pseudo-mutation analysis on production code in any language to find gaps in existing tests. Use when the user asks to find weak or shallow tests, discover untested edge cases, or check whether tests would catch a bug — e.g. \"would my tests catch it if someone changed the code\", \"would a subtle logic or boundary change slip past the current tests\", \"are my tests strong enough to catch a subtle bug\". Evaluates test effectiveness through mutation-style reasoning: analyzes mutation points (boundaries, boolean flips, null returns, exception removal, arithmetic changes) and checks whether tests would detect each. Polyglot: .NET, Python, TS/JS, Java, Go, Ruby, Rust, Swift, Kotlin, PowerShell, C++. DO NOT USE FOR: writing new tests (use code-testing-agent, or writing-mstest-tests for MSTest), detecting anti-patterns (use test-anti-patterns), measuring assertion diversity (use assertion-quality), or running actual mutation testing tools (Stryker, mutmut, PIT, cargo-mutants)." -license: MIT ---- - -# Test Gap Analysis via Pseudo-Mutation - -Analyze production code in any supported language by reasoning about hypothetical mutations and checking whether existing tests would catch them. This reveals blind spots where tests pass but would continue to pass even if the code were broken. - -> **Language-specific guidance**: Call the `test-analysis-extensions` skill to discover available extension files, then read the file matching the target codebase (e.g., `extensions/dotnet.md`, `extensions/python.md`, `extensions/typescript.md`). The extension file helps you find test files, recognize framework-specific assertion APIs, and identify language-specific null/None/nil patterns and error-handling idioms that map to the mutation catalog below. - -## Why Pseudo-Mutation Matters - -Code coverage tells you what code ran during tests. It does **not** tell you whether tests would fail if that code were wrong. A method can have 100% line coverage but zero tests that would catch a sign flip, an off-by-one error, or a removed null check. - -Pseudo-mutation analysis asks: _"If I changed this line, would any test fail?"_ When the answer is "no," you've found a test gap. - -| Coverage Metric | What It Measures | What It Misses | -|----------------|-----------------|----------------| -| Line coverage | Which lines executed | Whether assertions verify those lines' behavior | -| Branch coverage | Which branches taken | Whether both branches produce different asserted outcomes | -| **Mutation score** | Whether tests detect code changes | Nothing — this is the gold standard | - -This skill performs **static pseudo-mutation** — reasoning about mutations without actually running them — to approximate mutation testing at the speed of code review. - -## When to Use - -- User asks "would my tests catch a bug in this code?" -- User wants to find weak or shallow tests -- User wants to evaluate test effectiveness beyond coverage -- User asks for mutation testing or mutation analysis -- User asks "where are my tests blind?" -- User wants to prioritize which tests to strengthen -- The `code-testing-generator` agent (or any test-generation workflow) calls this skill as a pre-completion self-review step on freshly generated tests, before declaring the run finished - -## When Not to Use - -- User wants to write new tests from scratch (use `code-testing-agent` for any language, or `writing-mstest-tests` for MSTest specifically) -- User wants to detect test anti-patterns like flakiness or poor naming (use `test-anti-patterns`) -- User wants to measure assertion variety (use `assertion-quality`) -- User wants to run an actual mutation testing framework (Stryker for .NET/JS/TS, mutmut for Python, PIT for Java, go-mutesting for Go, cargo-mutants for Rust, mutant for Ruby) — help them directly with the tool -- User only wants code coverage numbers (out of scope) - -## Inputs - -| Input | Required | Description | -|-------|----------|-------------| -| Production code | Yes | The source files to analyze for mutation points | -| Test code | Yes | The test files that cover the production code | -| Focus area | No | A specific mutation category or code region to focus on | - -## Workflow - -### Step 1: Detect language and load extension - -Identify the target codebase's language and test framework. Call the `test-analysis-extensions` skill and read the matching extension file. The mutation catalog below uses language-neutral concepts; the extension file tells you how each concept maps in the language you are analyzing (e.g., `null` vs `None` vs `nil` vs `undefined`, `throw` vs `raise` vs `panic!` vs `return err`). - -### Step 2: Gather production and test code - -Read both the production code and its corresponding test files. If the user points to a directory, identify production/test pairs by convention — defaults differ by language: `.cs` ↔ `*Tests.cs`/`*.Tests.cs` (.NET), `foo.py` ↔ `test_foo.py`/`foo_test.py` (Python), `foo.ts` ↔ `foo.test.ts`/`foo.spec.ts` (JS/TS), `Foo.java` ↔ `FooTest.java`/`FooTests.java` (Java), `foo.go` ↔ `foo_test.go` (Go), `foo.rb` ↔ `foo_spec.rb`/`test_foo.rb` (Ruby), `lib.rs` ↔ inline `#[cfg(test)] mod tests` or `tests/foo.rs` (Rust), `Foo.swift` ↔ `FooTests.swift` (Swift), `Foo.kt` ↔ `FooTest.kt`/`FooSpec.kt` (Kotlin), `Foo.ps1` ↔ `Foo.Tests.ps1` (Pester), `foo.cpp` ↔ `foo_test.cpp`/`test_foo.cpp` (C++). - -Establish which production methods are exercised by which test methods — trace this through method calls in test code, setup, helper methods, and shared examples. - -### Step 3: Identify mutation points - -Scan the production code and annotate every location where a mutation could reveal a test gap. Use the mutation catalog below. - -#### Boundary Mutations - -| Original | Mutation | What it tests | -|----------|----------|---------------| -| `<` | `<=` | Off-by-one at upper bound | -| `>` | `>=` | Off-by-one at lower bound | -| `<=` | `<` | Boundary inclusion | -| `>=` | `>` | Boundary inclusion | -| `== 0` | `== 1` or `<= 0` | Zero-boundary handling | -| `i < length` | `i < length - 1` or `i <= length` | Loop boundary | -| `index + 1` | `index` or `index + 2` | Index arithmetic | - -#### Boolean and Logic Mutations - -| Original | Mutation | What it tests | -|----------|----------|---------------| -| `&&` | `\|\|` | Condition independence | -| `\|\|` | `&&` | Condition necessity | -| `!condition` | `condition` | Negation correctness | -| `if (x)` | `if (!x)` | Branch selection | -| `true` (constant) | `false` | Hardcoded assumption | -| `flag \|\| other` | `other` | Short-circuit first operand | - -#### Return Value Mutations - -| Original | Mutation | What it tests | -|----------|----------|---------------| -| `return result` | `return null` / `return None` / `return nil` / `return undefined` | Null/None/nil handling downstream | -| `return result` | `return default(T)` / `return T()` / `return ""` / `return 0` | Default value handling | -| `return true` | `return false` | Boolean return verification | -| `return list` | `return new List()` / `return []` / `return Array.Empty()` / `return make([]T, 0)` / `return Vec::new()` / `return @[]` | Empty collection handling | -| `return count` | `return 0` or `return count + 1` | Numeric return verification | -| `return string` | `return ""` or `return null`/`None`/`nil` | String return verification | -| `return Ok(x)` | `return Err(...)` (Rust) | Result/error variant | -| `return value, nil` | `return zero, err` (Go) | Error tuple | - -#### Exception / Error Removal Mutations - -| Original | Mutation | What it tests | -|----------|----------|---------------| -| `throw new ArgumentNullException(...)` (.NET) / `raise ValueError(...)` (Python) / `throw new Error(...)` (JS) / `throw new IllegalArgumentException(...)` (Java) / `panic!(...)` (Rust) / `panic(...)` (Go) / `raise ArgumentError` (Ruby) / `throw RuntimeException(...)` (Kotlin) / `throw FooError.bar` (Swift) / `throw "..."` (Pester) / `throw std::invalid_argument(...)` (C++) | _(remove entire throw/raise/panic)_ | Guard clause verification | -| `if (x == null) throw ...` / `if x is None: raise ...` / `if (!x) throw ...` / `if x == nil { return err }` (Go) / `assert!(x.is_some())` (Rust) | _(remove entire guard)_ | Null/None/nil guard testing | -| `if (!IsValid()) throw ...` / `if not is_valid(): raise ...` / etc. | _(remove entire check)_ | Validation testing | -| `return err` after error check (Go) | _(remove or swallow error)_ | Error propagation | -| `?` operator (Rust) | `.unwrap()` or `.expect(...)` | Error short-circuit | - -#### Arithmetic Mutations - -| Original | Mutation | What it tests | -|----------|----------|---------------| -| `a + b` | `a - b` | Addition correctness | -| `a - b` | `a + b` | Subtraction correctness | -| `a * b` | `a / b` | Multiplication correctness | -| `a / b` | `a * b` | Division correctness | -| `a % b` | `a / b` | Modulo correctness | -| `x++` | `x--` | Increment direction | -| `-value` | `value` | Sign flip | - -#### Null / None / Nil-Check Removal Mutations - -| Original | Mutation | What it tests | -|----------|----------|---------------| -| `if (x == null) return ...` / `if x is None: return ...` / `if (!x) return ...` / `if x == nil { return ... }` / `unless x; return; end` (Ruby) / `if x.is_none() { return ... }` (Rust) | _(remove null/None/nil check)_ | Null path coverage | -| `if (x != null) { ... }` / `if x is not None: ...` / `if x: ...` / `if x != nil { ... }` / `x?.let { ... }` (Kotlin) / `if let Some(x) = ... { ... }` (Rust) | _(always enter block)_ | Null/None/nil guard necessity | -| `x ?? defaultValue` (.NET/JS/Swift) / `x or defaultValue` (Python) / `x \|\| defaultValue` (JS) / `x.unwrap_or(defaultValue)` (Rust) / `x \|\| defaultValue` (Kotlin: `x ?: defaultValue`) | `x` (drop coalescing) | Null coalescing coverage | -| `x?.Method()` (.NET/Swift/Kotlin) / `x && x.method()` (JS) / `x and x.method()` (Python) | `x.Method()` | Null-conditional coverage | -| `x!` (.NET/TS/Swift) / `x!!` (Kotlin) / `.unwrap()` (Rust) | `x` | Null-forgiving / unwrap necessity | - -### Step 4: Evaluate each mutation against tests - -For each identified mutation point, reason about whether existing tests would detect the change: - -1. **Find covering tests** — Which test methods exercise the mutated line? Follow call chains through helpers and setup methods. -2. **Check assertion relevance** — Do those tests assert something that would change if the mutation were applied? A test that calls the method but only asserts an unrelated property would NOT catch the mutation. -3. **Classify the mutation** as: - -| Verdict | Meaning | Action | -|---------|---------|--------| -| **Killed** | At least one test would fail if this mutation were applied | No action needed — tests are effective here | -| **Survived** | No test would fail — the mutation would go undetected | This is a test gap — recommend a test improvement | -| **No coverage** | No test exercises this code path at all | Worse than survived — the code is untested | -| **Equivalent** | The mutation produces identical behavior (e.g., `x * 1` → `x / 1`) | Skip — not a real mutation | - -### Step 5: Calibrate findings - -Before reporting, apply these calibration rules: - -- **Don't flag trivial code.** Simple property getters (`return _name;`), auto-properties, and boilerplate don't need mutation analysis. Focus on logic, conditions, calculations, and error handling. -- **Consider defensive depth.** If a null guard has a survived mutation but the caller also checks for null, note the redundancy but rate it lower priority. -- **Equivalent mutations are not gaps.** If changing `>=` to `>` doesn't alter behavior because the `==` case is impossible given the domain, mark it Equivalent and skip. -- **Private methods reached through public API are valid targets.** Trace through the call chain — a private method called from a tested public method may still have survived mutations if the test doesn't assert the specific behavior affected. -- **Rate by risk, not count.** A single survived mutation in payment calculation logic is more important than five survived mutations in logging code. - -### Step 6: Report findings - -Present the analysis in this structure: - -1. **Summary** — Overall mutation score and key findings: - ``` - | Metric | Value | - |---------------------|----------| - | Mutation points | 42 | - | Killed | 28 (67%) | - | Survived | 10 (24%) | - | No coverage | 2 (5%) | - | Equivalent (skipped) | 2 (5%) | - ``` - -2. **Survived Mutations (Test Gaps)** — For each survived mutation, report: - - **Location**: File, method, line - - **Mutation category**: Boundary / Boolean / Return value / Exception / Arithmetic / Null-check - - **Original code**: The current code - - **Hypothetical mutation**: What would change - - **Why it survives**: Which tests cover this code and why their assertions miss it - - **Recommended fix**: A concrete test assertion or new test case that would kill this mutation - - Group by priority: high-risk survived mutations first (business logic, calculations, security checks), lower-risk last (logging, formatting). - -3. **No-Coverage Zones** — Code paths that no test reaches at all. These are worse than survived mutations. - -4. **Killed Mutations (Strengths)** — Briefly note areas where tests are effective. Highlight well-tested methods and strong assertion patterns. Don't enumerate every killed mutation — summarize. - -5. **Recommendations** — Prioritized list: - - Which survived mutations to address first (by risk) - - Specific test methods to add or strengthen - - Patterns the team can adopt to prevent future gaps (e.g., always test boundary values, always assert exception types) - -## Validation - -- [ ] Every mutation point was classified (Killed / Survived / No coverage / Equivalent) -- [ ] Every survived mutation includes the original code, the hypothetical change, and why tests miss it -- [ ] Every survived mutation includes a concrete recommended fix (a test assertion or test case) -- [ ] Equivalent mutations are correctly identified and excluded from the score -- [ ] Trivial code (simple getters, auto-properties) is excluded from analysis -- [ ] Findings are prioritized by risk, not just listed in source order -- [ ] Report includes strengths (killed mutations) alongside gaps -- [ ] Mutation categories are correctly labeled - -## Common Pitfalls - -| Pitfall | Solution | -|---------|----------| -| Analyzing trivial code | Skip auto-properties, simple getters, `@dataclass`/`record`/`data class` accessors, `#[derive]` impls — focus on logic | -| Reporting equivalent mutations as gaps | If the mutation doesn't change behavior, it's not a gap — mark Equivalent | -| Ignoring call chains | A private/internal/unexported helper called from a tested public method is reachable — trace the chain | -| Over-counting mutations in generated code | Skip auto-generated code (`*.g.cs`, `*.designer.cs`, `*_pb.go`, `*.pb.dart`), designer files, migration files, generated mocks/stubs | -| Recommending a new test for every survived mutation | Multiple survived mutations in the same method often share a single missing test — recommend one test that kills several | -| Ignoring production context | A survived mutation in `ToString()` / `__repr__` / `toString()` formatting is less important than one in `CalculateTotal()` — prioritize by business risk | -| Claiming 100% kill rate is required | Some mutations in low-risk code are acceptable to leave — acknowledge this in the report | -| Not considering integration with other skills | If gaps are found, mention that `code-testing-agent` (any language) or `writing-mstest-tests` (MSTest-specific) can help write the missing tests, and `test-anti-patterns` can audit existing test quality | -| Forgetting Go's error idiom | Removing `if err != nil { return err }` is a valid mutation target only when the function actually does something else with `err` (e.g., wrap, log, branch). Bare passthroughs in idiomatic Go are not meaningful gaps. | -| Forgetting Rust's `?` operator | `?` propagates `Err`/`None` short-circuits. Mutating `expr?` → `expr.unwrap()` panics instead of returning — flag as Exception/Panic mutation when tests should observe the propagated error. | diff --git a/.gitignore b/.gitignore index ed459d6..136dc75 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ node_modules **/[Cc]ompiler/[Rr]esources/**/*.js deploy/ /.build +/.agents/ +/docs/ +/.tmp/ diff --git a/docs/sdd/etapa-1/01-reflection-helper.md b/docs/sdd/etapa-1/01-reflection-helper.md deleted file mode 100644 index a0b2d86..0000000 --- a/docs/sdd/etapa-1/01-reflection-helper.md +++ /dev/null @@ -1,123 +0,0 @@ -# 01 - ReflectionHelper - -## Specification - -Corrigir a resolucao de propriedades a partir de `Expression>` para usar o membro representado pela propria expression tree, preservando API publica e rejeitando expressoes nao suportadas com erro claro. - -Fora do escopo: MemberPath completo, objetos aninhados, Value Objects, composicao de conventions, MappingRegistry, redesign de cache, records, constructor mapping, mudancas em Dommel e atualizacoes de frameworks ou dependencias. - -## Discovery - -Arquivos analisados: - -- `src/Dapper.FluentMap/Utils/ReflectionHelper.cs` -- `src/Dapper.FluentMap/Mapping/EntityMap.cs` -- `src/Dapper.FluentMap/Mapping/PropertyMap.cs` -- `test/Dapper.FluentMap.Tests/ReflectionHelperTests.cs` -- `test/Dapper.FluentMap.Tests/ManualMappingTests.cs` -- `test/Dapper.FluentMap.Tests/TestEntity.cs` -- `README.md` - -Consumidores de `ReflectionHelper.GetMemberInfo`: - -- `EntityMapBase.Map(Expression> expression)`, que converte o retorno para `PropertyInfo`. -- Testes unitarios em `ReflectionHelperTests`. - -Formatos aceitos atualmente: - -- `LambdaExpression` cujo corpo seja `MemberExpression`. -- `UnaryExpression` com `ExpressionType.Convert`, usado por propriedades value type em `Expression>`. -- Acesso aninhado simples, como `x => x.Email.Address`, retornando a propriedade final. - -Comportamentos ja cobertos: - -- propriedade comum (`Id`); -- propriedade herdada em entidade derivada; -- nullable/value type com `Convert`; -- propriedade aninhada em value object; -- propriedade aninhada cujo nome coincide com membro de tipo do sistema (`String.Length`). - -Lacunas encontradas: - -- propriedade final cujo nome coincide com outro membro publico do tipo da propria propriedade, como `string.Format` ou `TimeSpan.Duration`; -- expression invalida sem `MemberExpression`, que atualmente retorna `null` e tende a falhar depois com erro indireto. - -Causa raiz: no caminho de `MemberAccess`, o helper obtem `memberExpression.Member`, mas depois procura novamente membros por nome em tipos relacionados (`GetMembers().FirstOrDefault(...)` e `GetMember(member.Name)[0]`). Essa nova busca depende da ordem de reflection e pode retornar `MethodInfo` ou outro membro homonimo em vez do `PropertyInfo` que a expression tree ja identificou. - -## Decision - -Causa raiz confirmada: a resolucao por nome e por primeiro resultado de reflection e ambigua. - -Estrategia escolhida: - -- Desembrulhar `Lambda` e `Convert`. -- Em `MemberAccess`, retornar diretamente o `PropertyInfo` presente em `MemberExpression.Member`. -- Rejeitar `MemberExpression` que nao represente propriedade com `ArgumentException`. -- Rejeitar expressoes nao suportadas com `ArgumentException` clara. -- Manter a assinatura publica de `ReflectionHelper.GetMemberInfo(LambdaExpression)`. - -Alternativas descartadas: - -- Filtrar `GetMember(...)` por `PropertyInfo`: ainda reexecuta uma busca desnecessaria por nome e pode introduzir ambiguidades futuras. -- Criar uma nova abstracao de parsing ou MemberPath: fora do escopo desta entrega. -- Alterar `EntityMap.Map(...)` para nova API publica: desnecessario para corrigir a falha e aumentaria a superficie publica. - -Impacto esperado: - -- Expressoes validas passam a resolver exatamente a propriedade representada pela expression tree. -- Colisoes de nome com membros de `string`, `TimeSpan` ou outros tipos deixam de produzir `InvalidCastException` indireta. -- Expressoes invalidas passam a falhar mais cedo com erro explicito. - -Compatibilidade preservada: - -- API publica e assinaturas existentes. -- Suporte a propriedade simples, propriedade herdada, value types com `Convert` e acesso aninhado ja existente. -- Sem mudancas em Dommel, build, targets ou dependencias. - -## Delivery - -- `ReflectionHelper.GetMemberInfo` passou a: - - validar `lambda == null` com `ArgumentNullException`; - - desembrulhar `Lambda` e `Convert`; - - retornar diretamente o `PropertyInfo` de `MemberExpression.Member`; - - rejeitar membros que nao sejam propriedades com `ArgumentException`; - - rejeitar expressoes nao suportadas com `ArgumentException`. -- Testes de regressao adicionados em `ReflectionHelperTests` para: - - propriedade comum com nome que colide com membro de `string` (`Format`); - - propriedade value type com `Convert` e nome que colide com membro de `TimeSpan` (`Duration`); - - expression invalida (`e.Id.ToString()`). - -Arquivos alterados: - -- `src/Dapper.FluentMap/Utils/ReflectionHelper.cs` -- `test/Dapper.FluentMap.Tests/ReflectionHelperTests.cs` -- `docs/sdd/etapa-1/README.md` -- `docs/sdd/etapa-1/status.md` -- `docs/sdd/etapa-1/decisions.md` -- `docs/sdd/etapa-1/01-reflection-helper.md` - -## Validation - -- `dotnet test test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --filter "FullyQualifiedName~ReflectionHelperTests"` - - Resultado inicial: falhou antes de executar por metadado corrompido no cache NuGet global (`microsoft.netcore.targets`). -- Reexecutado com `NUGET_PACKAGES` temporario: - - restore e build dos testes concluiram; - - execucao abortou porque o runtime `Microsoft.NETCore.App 3.1.0` nao esta instalado na maquina. -- `dotnet build src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release` - - Resultado: sucesso, 0 warnings, 0 erros. -- Harness temporario `net8.0` referenciando o projeto atual: - - Resultado: sucesso; propriedade comum, value type com `Convert`, colisoes `Format`/`Duration` e expression invalida se comportaram como esperado. -- Harness temporario `net8.0` compilando `ReflectionHelper.cs` de `HEAD` antes da alteracao: - - Resultado: falhou como esperado em `Format`, retornando `RuntimeMethodInfo` em vez de `PropertyInfo`. -- `dotnet restore .\Dapper.FluentMap.sln` - - Resultado: sucesso com cache NuGet temporario. -- `dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore` - - Resultado: sucesso, 0 warnings, 0 erros. -- `dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build` - - Resultado: abortou porque os projetos de teste miram `netcoreapp3.1` e o runtime `Microsoft.NETCore.App 3.1.0` nao esta instalado. - -Riscos e limitacoes: - -- A suite oficial nao foi executada ate o fim neste ambiente por ausencia do runtime `netcoreapp3.1`. -- A mudanca torna expressoes invalidas mais explicitas via `ArgumentException`; isso substitui falhas indiretas anteriores como `null` ou `InvalidCastException`. -- Dommel nao recebeu alteracao funcional. diff --git a/docs/sdd/etapa-1/02-mapping-composition.md b/docs/sdd/etapa-1/02-mapping-composition.md deleted file mode 100644 index 890eb74..0000000 --- a/docs/sdd/etapa-1/02-mapping-composition.md +++ /dev/null @@ -1,198 +0,0 @@ -## Specification - -Corrigir a composicao entre mappings explicitos, conventions e fallback padrao do Dapper para que a resolucao de membros siga uma cadeia previsivel: - -1. mapping explicito; -2. convention; -3. `DefaultTypeMap` do Dapper. - -Objetivos: - -- permitir coexistencia de `AddMap(...)` e `AddConvention(...).ForEntity(...)` para o mesmo tipo; -- permitir que mapping explicito sobrescreva convention para a mesma propriedade; -- preservar o fallback do Dapper quando nem mapping explicito nem convention resolvem a coluna; -- eliminar o comportamento em que a ultima chamada a `SqlMapper.SetTypeMap(...)` determina sozinha a estrategia ativa; -- preservar a API publica e evitar o `MappingRegistry` completo previsto para entrega posterior. - -Fora do escopo: - -- MappingRegistry definitivo; -- redesign amplo de cache; -- MemberPath; -- materializacao aninhada; -- Value Objects; -- profiles por tipo; -- constructor mapping; -- alteracoes funcionais no Dommel. - -## Discovery - -Arquivos analisados: - -- `src/Dapper.FluentMap/FluentMapper.cs` -- `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs` -- `src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs` -- `src/Dapper.FluentMap/TypeMaps/FluentTypeMap.cs` -- `src/Dapper.FluentMap/TypeMaps/FluentConventionTypeMap.cs` -- `src/Dapper.FluentMap/TypeMaps/MultiTypeMap.cs` -- `src/Dapper.FluentMap/Utils/FluentMapConfigurationExtensions.cs` -- `src/Dapper.FluentMap/Mapping/EntityMap.cs` -- `src/Dapper.FluentMap/Mapping/PropertyMap.cs` -- `test/Dapper.FluentMap.Tests/ManualMappingTests.cs` -- `test/Dapper.FluentMap.Tests/ConventionTests.cs` -- `docs/sdd/etapa-1/01-reflection-helper.md` -- `docs/sdd/etapa-1/decisions.md` -- `docs/sdd/etapa-1/status.md` - -`status.md` confirmou que `01 - ReflectionHelper` esta `Concluido`. - -Pontos que chamavam `SqlMapper.SetTypeMap(...)`: - -- `FluentMapper.AddTypeMap()` - - instalava `new FluentMapTypeMap()`; - - chamado por `FluentMapConfiguration.AddMap(...)`. -- `FluentMapper.AddTypeMap(Type entityType)` - - instalava `FluentMapTypeMap<>` via reflection; - - chamado por assembly scanning de maps via `ApplyMapsFromAssemblies(...)`. -- `FluentMapper.AddConventionTypeMap()` - - instalava `new FluentConventionTypeMap()`; - - chamado por `FluentConventionConfiguration.ForEntity()`. -- `FluentMapper.AddConventionTypeMap(Type entityType)` - - instalava `FluentConventionTypeMap<>` via reflection; - - chamado por `ForEntitiesInCurrentAssembly(...)` e `ForEntitiesInAssembly(...)`. - -Fluxo atual antes da mudanca: - -- `AddMap(...)` registra o `IEntityMap` em `FluentMapper.EntityMaps` e instala `FluentMapTypeMap`. -- `ApplyMapsFromAssemblies(...)` encontra classes que implementam `IEntityMap<>` e chama `AddMap(...)` por reflection. -- `AddConvention()` cria um `FluentConventionConfiguration`. -- `ForEntity()` materializa `PropertyMap`s da convention, registra a convention em `FluentMapper.TypeConventions` e instala `FluentConventionTypeMap`. -- `ForEntitiesInCurrentAssembly(...)` e `ForEntitiesInAssembly(...)` repetem o mesmo processo para cada tipo exportado filtrado. -- `FluentMapTypeMap` consultava mappings explicitos e depois caia para `DefaultTypeMap`. -- `FluentConventionTypeMap` consultava conventions e depois caia para `DefaultTypeMap`. - -Causa raiz: - -- mappings explicitos e conventions eram estrategias separadas instaladas diretamente no registro global do Dapper; -- para o mesmo tipo de entidade, a chamada mais recente a `SqlMapper.SetTypeMap(...)` substituia a anterior; -- portanto `AddMap(...); AddConvention(...).ForEntity()` deixava apenas convention + default ativa; -- e `AddConvention(...).ForEntity(); AddMap(...)` deixava apenas explicito + default ativo; -- cada type map ja tinha fallback proprio para `DefaultTypeMap`, mas nao havia um type map unico que compusesse explicito e convention antes do fallback. - -Observacoes sobre cache: - -- `MultiTypeMap.TypePropertyMapCache` e compartilhado entre type maps; -- a chave antiga usava apenas `type.FullName` e `columnName`; -- em uma composicao ingênua com dois `CustomPropertyTypeMap`s, um miss do resolver explicito poderia ser cacheado e impedir a convention de ser consultada para a mesma coluna; -- a Entrega 4 continua sendo o local apropriado para redesenhar registry/cache de forma completa. - -## Decision - -Design escolhido: - -- usar `FluentMapTypeMap` como estrategia composta instalada tanto por mappings explicitos quanto por conventions; -- alterar `FluentMapTypeMap` para resolver em uma unica funcao: - - primeiro mappings explicitos em `FluentMapper.EntityMaps`; - - depois conventions em `FluentMapper.TypeConventions`; - - por fim o `DefaultTypeMap` ja presente no `MultiTypeMap`; -- manter `FluentConventionTypeMap` publico e funcional para compatibilidade, mas deixar de instala-lo nos fluxos internos de `AddConventionTypeMap(...)`; -- mover a comparacao de coluna para `MultiTypeMap.MatchColumnNames(...)`, evitando duplicar a regra case-sensitive/case-insensitive entre os type maps. - -Precedencia final: - -1. se a coluna casa com um mapping explicito, ele vence; -2. se o mapping explicito e `Ignore()`, a resolucao para aquela coluna para sem cair no default; -3. se a coluna nao casa com mapping explicito, conventions podem resolver; -4. conventions nao resolvem propriedades que tenham mapping explicito, permitindo override explicito da convention para a mesma propriedade; -5. se nenhuma regra especial resolver, `DefaultTypeMap` permanece disponivel. - -Comportamento em conflito: - -- mapping explicito para a mesma coluna vence por ser consultado antes; -- mapping explicito para a mesma propriedade remove essa propriedade dos candidatos por convention; -- ambiguidades dentro de uma convention continuam usando a excecao existente quando mais de um `PropertyMap` casa com a mesma coluna. - -Compatibilidade: - -- nenhuma API publica foi removida ou alterada; -- `FluentConventionTypeMap` permanece publico; -- `AddConventionTypeMap(...)` passa a instalar `FluentMapTypeMap` para obter a composicao; -- consumidores que observam diretamente `SqlMapper.GetTypeMap(typeof(T))` apos configurar apenas convention podem notar o tipo concreto diferente, mas o comportamento funcional esperado de convention + default permanece. - -Alternativas descartadas: - -- apenas trocar a ordem de chamadas de `SetTypeMap`: manteria o comportamento dependente de ordem e nao comporia as estrategias; -- criar agora um `MappingRegistry`: resolveria parte do problema, mas antecipa a Entrega 4 e ampliaria o escopo; -- empilhar dois `CustomPropertyTypeMap`s independentes: conflitaria com o cache compartilhado quando o primeiro resolver cacheasse misses antes do segundo ser consultado; -- remover `FluentConventionTypeMap`: seria uma quebra desnecessaria de superficie publica. - -## Delivery - -Implementacao: - -- `FluentMapper.AddConventionTypeMap(...)` agora delega para `AddTypeMap(...)`, instalando o type map composto. -- `FluentMapTypeMap` agora consulta mappings explicitos e conventions antes do fallback default. -- `FluentMapTypeMap` ignora candidates de convention para propriedades ja mapeadas explicitamente. -- `MultiTypeMap` recebeu `MatchColumnNames(...)` protegido para compartilhar a regra de comparacao. -- `FluentConventionTypeMap` passou a usar uma chave de cache distinta da chave do type map composto. - -Testes adicionados em `test/Dapper.FluentMap.Tests/MappingCompositionTests.cs`: - -- somente mapping explicito resolve coluna explicita; -- somente convention resolve coluna por prefixo; -- `DefaultTypeMap` resolve coluna quando nenhuma regra especial casa; -- mapping explicito e convention resolvem propriedades diferentes no mesmo tipo; -- mapping explicito sobrescreve convention para a mesma propriedade; -- ordem `AddMap(...)` antes de `AddConvention(...)` nao impede composicao; -- ordem `AddConvention(...)` antes de `AddMap(...)` nao impede composicao; -- case sensitivity de mapping explicito e convention case-insensitive permanecem independentes. - -Implicacoes para MappingRegistry: - -- a entrega cria uma cadeia de resolucao observavel, mas ainda consulta os dicionarios globais existentes; -- a Entrega 4 deve substituir essa consulta direta por descritores/registry mais explicitos e lidar com invalidacao de cache; -- a chave de cache continua deliberadamente simples e nao resolve colisoes por assembly, reinicializacao tardia ou todas as dimensoes de configuracao. - -## Validation - -Ambiente: - -- SDK: `10.0.302` -- test runner detectado: VSTest com xUnit v2 (`Microsoft.NET.Test.Sdk`, `xunit`, `xunit.runner.visualstudio`) -- projetos de teste: `netcoreapp3.1` - -Comandos executados: - -- `dotnet test test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --filter "FullyQualifiedName~MappingCompositionTests"` - - resultado: falhou antes de executar por metadado corrompido no cache NuGet global (`microsoft.netcore.targets`). -- Com `NUGET_PACKAGES` temporario no workspace: - - `dotnet test test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --filter "FullyQualifiedName~MappingCompositionTests"` - - resultado: restore e build passaram; execucao abortou porque `Microsoft.NETCore.App 3.1.0` nao esta instalado. -- Harness temporario `net8.0` referenciando o projeto atual: - - resultado: passou todos os cenarios de composicao, override, ordem, fallback e case sensitivity. -- Com `NUGET_PACKAGES` temporario: - - `dotnet build src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release` - - resultado: sucesso, 0 warnings, 0 erros. -- Com `NUGET_PACKAGES` temporario: - - `dotnet build Dapper.FluentMap.sln --configuration Release` - - resultado: sucesso, 0 warnings, 0 erros. -- `dotnet restore` - - resultado: falhou por metadado corrompido no cache NuGet global (`microsoft.netcore.targets`). -- Com `NUGET_PACKAGES=%TEMP%\dfm-nuget-packages-composition`: - - `dotnet restore` - - resultado: sucesso. -- Com `NUGET_PACKAGES=%TEMP%\dfm-nuget-packages-composition`: - - `dotnet build --configuration Release` - - resultado: sucesso, 0 warnings, 0 erros. -- Com `NUGET_PACKAGES=%TEMP%\dfm-nuget-packages-composition`: - - `dotnet test --configuration Release --no-build` - - resultado: abortou porque `Microsoft.NETCore.App 3.1.0` nao esta instalado para os projetos de teste core e Dommel. -- Com `NUGET_PACKAGES=%TEMP%\dfm-nuget-packages-composition`: - - `dotnet test test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --no-build` - - resultado: abortou porque `Microsoft.NETCore.App 3.1.0` nao esta instalado. - -Limitacoes: - -- a suite oficial compilou, mas nao executou ate o fim neste ambiente por ausencia do runtime `netcoreapp3.1`; -- Dommel nao recebeu alteracao funcional; a solution completa compilou em Release; -- pack nao foi executado porque a entrega nao altera metadados ou empacotamento NuGet. diff --git a/docs/sdd/etapa-1/03-dapper-integration-tests.md b/docs/sdd/etapa-1/03-dapper-integration-tests.md deleted file mode 100644 index 2abbefa..0000000 --- a/docs/sdd/etapa-1/03-dapper-integration-tests.md +++ /dev/null @@ -1,202 +0,0 @@ -# 03 - Testes De Integracao Com Dapper - -## Specification - -Criar uma baseline pequena e deterministica de testes de integracao que exercite o comportamento publico do `Dapper.FluentMap` atraves do proprio Dapper materializando objetos a partir de SQL. - -O fluxo protegido e: - -```text -SQL -| -Dapper Query -| -ITypeMap do FluentMap -| -Objeto materializado -| -Assert sobre comportamento observavel -``` - -Fora do escopo: - -- Docker, Testcontainers, PostgreSQL, SQL Server ou servicos externos; -- redesign de estado global, registry ou cache; -- alteracoes funcionais no core; -- cobertura exaustiva de todos os testes unitarios existentes; -- mudanca de target dos projetos de teste. - -## Discovery - -Arquivos analisados: - -- `AGENTS.md` -- `docs/sdd/etapa-1/README.md` -- `docs/sdd/etapa-1/status.md` -- `docs/sdd/etapa-1/decisions.md` -- `docs/sdd/etapa-1/01-reflection-helper.md` -- `docs/sdd/etapa-1/02-mapping-composition.md` -- `Dapper.FluentMap.sln` -- `src/Dapper.FluentMap/Dapper.FluentMap.csproj` -- `src/Dapper.FluentMap/FluentMapper.cs` -- `src/Dapper.FluentMap/TypeMaps/MultiTypeMap.cs` -- `src/Dapper.FluentMap/TypeMaps/FluentTypeMap.cs` -- `src/Dapper.FluentMap/TypeMaps/FluentConventionTypeMap.cs` -- `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` -- `test/Dapper.FluentMap.Tests/ManualMappingTests.cs` -- `test/Dapper.FluentMap.Tests/ConventionTests.cs` -- `test/Dapper.FluentMap.Tests/MappingCompositionTests.cs` -- `test/Dapper.FluentMap.Tests/TestEntity.cs` - -Entregas anteriores: - -- `01 - ReflectionHelper` esta marcada como `Concluido` em `status.md`. -- `02 - Composicao de mappings` esta marcada como `Concluido` em `status.md`. - -Suite atual: - -- framework de testes: xUnit v2; -- runner: VSTest (`Microsoft.NET.Test.Sdk` + `xunit.runner.visualstudio`); -- target real dos projetos de teste: `netcoreapp3.1`; -- SDK local: `10.0.302`; -- nao ha `global.json`, `Directory.Build.props` ou `Directory.Packages.props` relevantes; -- a suite principal nao tinha testes com conexao real ou provider SQL; -- os testes existentes exercitavam `SqlMapper.GetTypeMap(...).GetMember(...)`, mas nao `Query`. - -Dependencias existentes: - -- `Dapper.FluentMap` referencia `Dapper` `2.0.35`; -- `Dapper.FluentMap.Tests` referenciava apenas o projeto core, xUnit e VSTest. - -Estado global e isolamento: - -- `FluentMapper.EntityMaps` e `FluentMapper.TypeConventions` sao dicionarios estaticos globais; -- `FluentMapper.Initialize(...)` reutiliza uma instancia estatica de `FluentMapConfiguration`; -- `SqlMapper.SetTypeMap(...)` altera o registro global de type maps do Dapper por tipo; -- `MultiTypeMap.TypePropertyMapCache` e um cache estatico compartilhado, sem reset publico; -- `ManualMappingTests.cs` desabilita paralelismo no assembly com `CollectionBehavior(DisableTestParallelization = true)`; -- testes existentes limpam `EntityMaps` e `TypeConventions`; `MappingCompositionTests` tambem chama `SqlMapper.SetTypeMap(type, null)` para os tipos afetados. - -Riscos identificados: - -- testes que reutilizam o mesmo tipo com configuracoes diferentes podem sofrer interferencia por `SqlMapper.SetTypeMap`; -- o cache estatico pode reter misses ou hits por chave `type.FullName + columnName`; -- nao ha mecanismo publico ou interno dedicado para reset atomico do estado global; -- reabilitar paralelismo sem resolver o estado global seria inseguro. - -## Decision - -Provider escolhido: `Microsoft.Data.Sqlite` com SQLite in-memory. - -Motivos: - -- roda localmente e sem rede; -- nao exige Docker, servico externo ou processo separado; -- permite exercitar `IDbConnection`, SQL real e `Dapper.QuerySingle`; -- adiciona somente uma dependencia de teste; -- a versao `3.1.32` e compativel com o target atual `netcoreapp3.1`, evitando misturar modernizacao de runtime nesta entrega. - -Estrategia de banco: - -- cada teste abre uma nova `SqliteConnection` com `Data Source=:memory:`; -- os testes usam `SELECT` direto para projetar uma linha deterministica; -- a conexao e descartada ao final do teste; -- nenhum arquivo temporario de banco e criado. - -Estrategia de isolamento: - -- cada teste usa um tipo de entidade especifico, evitando colisao no cache por tipo e coluna; -- antes e depois de cada teste, `EntityMaps` e `TypeConventions` sao limpos; -- antes e depois de cada teste, `SqlMapper.SetTypeMap(type, null)` remove o type map do Dapper para os tipos tocados; -- o cache interno de `MultiTypeMap` nao e limpo porque nao ha API para isso e a Entrega 4 deve tratar registry/cache. - -Estrategia de paralelismo: - -- o assembly ja tem paralelismo desabilitado; -- isso continua necessario por causa do estado global do FluentMap e do registro global do Dapper; -- a entrega nao tenta resolver essa restricao arquitetural. - -Cenarios selecionados: - -- mapping padrao do Dapper; -- mapping explicito de nome de coluna; -- convention por prefixo; -- composicao explicit + convention; -- override explicito sobre convention; -- correcao da Entrega 1 exercitada via materializacao real com propriedade `Format`; -- mapping explicito case-insensitive. - -## Delivery - -Implementacao: - -- adicionada dependencia `Microsoft.Data.Sqlite` `3.1.32` ao projeto `Dapper.FluentMap.Tests`; -- adicionada a classe `DapperIntegrationTests`; -- cada teste usa `Dapper.QuerySingle` contra SQLite in-memory; -- os asserts validam propriedades materializadas, nao detalhes internos de `ITypeMap`; -- nenhum codigo de producao foi alterado. - -Testes adicionados: - -- `DefaultDapperMappingShouldMaterializeProperties` -- `ExplicitMappingShouldMaterializeConfiguredColumn` -- `ConventionShouldMaterializeConfiguredColumns` -- `ExplicitMappingAndConventionShouldMaterializeTogether` -- `ExplicitMappingShouldOverrideConventionDuringMaterialization` -- `ExpressionResolvedPropertyShouldMaterializeWhenNameCollidesWithStringMember` -- `CaseInsensitiveExplicitMappingShouldMaterializeColumnWithDifferentCase` - -Arquivos alterados: - -- `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` -- `test/Dapper.FluentMap.Tests/DapperIntegrationTests.cs` -- `docs/sdd/etapa-1/status.md` -- `docs/sdd/etapa-1/decisions.md` -- `docs/sdd/etapa-1/03-dapper-integration-tests.md` - -## Validation - -Comandos executados: - -- `dotnet restore .\Dapper.FluentMap.sln` - - resultado: falhou por metadado corrompido no cache NuGet global (`microsoft.netcore.targets`). -- `NUGET_PACKAGES=.\.nuget-temp dotnet restore .\Dapper.FluentMap.sln` - - resultado: sucesso. -- `dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore` - - resultado: sucesso antes da alteracao. -- `DOTNET_ROLL_FORWARD=Major dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --no-build --filter "FullyQualifiedName~MappingCompositionTests"` - - resultado: sucesso antes da alteracao, 8 testes aprovados. -- `NUGET_PACKAGES=.\.nuget-temp dotnet restore` - - resultado: sucesso. -- `NUGET_PACKAGES=.\.nuget-temp dotnet build --configuration Release --no-restore` - - resultado: sucesso, 0 warnings, 0 erros. -- `NUGET_PACKAGES=.\.nuget-temp DOTNET_ROLL_FORWARD=Major dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --no-build --filter "FullyQualifiedName~DapperIntegrationTests"` - - resultado: sucesso, 7 testes aprovados. -- `NUGET_PACKAGES=.\.nuget-temp DOTNET_ROLL_FORWARD=Major dotnet test --configuration Release --no-build` - - resultado: sucesso, 38 testes aprovados no projeto core e 7 testes aprovados no projeto Dommel. -- `NUGET_PACKAGES=.\.nuget-temp DOTNET_ROLL_FORWARD=Major dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --no-build` - - resultado: sucesso, 38 testes aprovados. -- `NUGET_PACKAGES=.\.nuget-temp DOTNET_ROLL_FORWARD=Major dotnet test --configuration Release` - - resultado: sucesso, 38 testes aprovados no projeto core e 7 testes aprovados no projeto Dommel. -- `NUGET_PACKAGES=.\.nuget-temp DOTNET_ROLL_FORWARD=Major dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release` - - resultado: sucesso, 38 testes aprovados. - -Observacoes: - -- `DOTNET_ROLL_FORWARD=Major` foi necessario apenas para executar os testes `netcoreapp3.1` neste ambiente, que possui runtimes 8.0 e 10.0, mas nao o runtime 3.1. -- Os testes de integracao usam apenas SQLite in-memory e nao persistem arquivos temporarios de banco. -- A primeira tentativa de build apos adicionar a dependencia falhou porque restore e build foram executados em paralelo; apos restore sequencial, o build passou. - -## Follow-Up Para Entrega 4 - -- Criar uma estrategia explicita para reset ou substituicao segura do estado global em testes. -- Definir invalidacao do cache estatico quando mapas ou conventions forem alterados. -- Avaliar chave de cache estruturada que considere tipo, coluna, comparacao e estrategia instalada. -- Avaliar encapsulamento dos dicionarios publicos globais antes de qualquer tentativa de reabilitar paralelismo. -- Considerar se `FluentMapper.Initialize(...)` deve continuar reutilizando uma configuracao estatica mutavel. - -## Achado De Baseline - -- A execucao completa da suite revelou que `ReflectionHelperTests.GetMemberInfo_ReturnsProperty_OfDerivedType` ainda esperava o `PropertyInfo` retornado por `typeof(DerivedTestEntity).GetProperty("Id")`. -- Essa expectativa conflitava com a decisao da Entrega 1 de retornar diretamente o `MemberExpression.Member`, que para propriedade herdada aponta para `TestEntity.Id`. -- O teste foi ajustado para validar a decisao ja documentada; nenhum codigo de producao foi alterado. diff --git a/docs/sdd/etapa-1/04-mapping-registry-cache.md b/docs/sdd/etapa-1/04-mapping-registry-cache.md deleted file mode 100644 index 89e1818..0000000 --- a/docs/sdd/etapa-1/04-mapping-registry-cache.md +++ /dev/null @@ -1,225 +0,0 @@ -# 04 - MappingRegistry E Cache - -## Specification - -Introduzir uma estrutura interna de registry para centralizar a configuracao de mappings do core e substituir o cache ativo de propriedades, antes baseado em chaves de string concatenadas, por chaves estruturadas. - -Objetivos: - -- preservar a API publica existente; -- manter a composicao definida na Entrega 2: mapping explicito, convention e fallback do Dapper; -- manter a baseline de integracao da Entrega 3; -- reduzir o espalhamento de estado entre `FluentMapper`, type maps e caches; -- definir invalidacao explicita do cache nas reconfiguracoes feitas pela API; -- preparar terreno para melhorias futuras sem redesenhar a API publica. - -Fora do escopo: - -- MemberPath; -- nested object materialization; -- Value Objects; -- inheritance mappings; -- records; -- constructor mapping; -- Roslyn analyzers; -- source generators; -- AOT; -- multiplos profiles; -- redesign completo da API publica. - -## Discovery - -Entregas anteriores confirmadas em `status.md`: - -- `01 - ReflectionHelper`: Concluído. -- `02 - Composicao de mappings`: Concluído. -- `03 - Testes de integracao`: Concluído. - -Arquivos analisados: - -- `AGENTS.md` -- `docs/sdd/etapa-1/README.md` -- `docs/sdd/etapa-1/status.md` -- `docs/sdd/etapa-1/decisions.md` -- `docs/sdd/etapa-1/01-reflection-helper.md` -- `docs/sdd/etapa-1/02-mapping-composition.md` -- `docs/sdd/etapa-1/03-dapper-integration-tests.md` -- `src/Dapper.FluentMap/FluentMapper.cs` -- `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs` -- `src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs` -- `src/Dapper.FluentMap/TypeMaps/FluentTypeMap.cs` -- `src/Dapper.FluentMap/TypeMaps/FluentConventionTypeMap.cs` -- `src/Dapper.FluentMap/TypeMaps/MultiTypeMap.cs` -- `test/Dapper.FluentMap.Tests/ManualMappingTests.cs` -- `test/Dapper.FluentMap.Tests/ConventionTests.cs` -- `test/Dapper.FluentMap.Tests/MappingCompositionTests.cs` -- `test/Dapper.FluentMap.Tests/DapperIntegrationTests.cs` - -Estado relacionado a mapping antes da mudanca: - -| Estado | Escrita | Leitura | Lifetime | Thread safety | Invalidacao | -|---|---|---|---|---|---| -| `FluentMapper.EntityMaps` | `FluentMapConfiguration.AddMap` e testes por `Clear()` | `FluentTypeMap`, Dommel e testes | estatico por processo | `ConcurrentDictionary`, mas valores continuam mutaveis | manual em testes; sem cache reset | -| `FluentMapper.TypeConventions` | `FluentConventionConfiguration` e testes por `Clear()` | `FluentTypeMap`, `FluentConventionTypeMap`, Dommel e testes | estatico por processo | `ConcurrentDictionary`, mas lista era atualizada por helper nao atomico | manual em testes; sem cache reset | -| `_configuration` | `FluentMapper.Initialize` reutiliza a mesma instancia | callbacks de configuracao | estatico por processo | sem sincronizacao propria | nao aplicavel | -| `SqlMapper.SetTypeMap` | `FluentMapper.AddTypeMap` e `AddConventionTypeMap` | Dapper durante materializacao | global no Dapper por tipo | responsabilidade do Dapper | testes removiam por tipo | -| `MultiTypeMap.TypePropertyMapCache` | `FluentTypeMap` e `FluentConventionTypeMap` | `FluentTypeMap` e `FluentConventionTypeMap` | estatico por processo | `ConcurrentDictionary` | sem reset definido | - -Problemas resolviveis nesta entrega: - -- remover o cache ativo baseado em strings como `FluentMapTypeMap;{type.FullName};{columnName}`; -- centralizar escrita, leitura, instalacao de type map e invalidacao em um componente interno; -- tornar o reset de testes atomico para dicionarios, cache e type maps do Dapper dos tipos tocados; -- atualizar conventions com `ConcurrentDictionary.AddOrUpdate` e copia de lista, evitando mutacao in-place do valor compartilhado; -- manter os campos publicos existentes como visoes do storage interno por compatibilidade. - -Problemas deliberadamente nao resolvidos: - -- consumidores ainda podem mutar diretamente `EntityMaps` e `TypeConventions`, pois esses campos publicos fazem parte da compatibilidade existente; -- o registro global do Dapper continua necessario porque a extensibilidade de materializacao passa por `SqlMapper.SetTypeMap`; -- o paralelismo da suite continua desabilitado por causa de estado global historico e por Dommel ainda consumir os dicionarios publicos diretamente; -- `MultiTypeMap.TypePropertyMapCache` permanece como membro protegido para evitar quebra de compatibilidade, mas deixou de ser usado pelo core. - -## Decision - -Design adotado: - -```text -FluentMapper public facade - | - v -internal MappingRegistry - | - +-- EntityMaps / TypeConventions public-compatible storage - +-- structured mapping cache - +-- SqlMapper.SetTypeMap integration - | - v -FluentMapTypeMap / FluentConventionTypeMap - | - v -Dapper DefaultTypeMap fallback -``` - -Dono do estado: - -- `MappingRegistry` e o dono interno do storage de `EntityMaps`, `TypeConventions` e cache. -- `FluentMapper.EntityMaps` e `FluentMapper.TypeConventions` continuam publicos e apontam para os mesmos dicionarios do registry. -- `FluentMapConfiguration` e `FluentConventionConfiguration` passam a escrever via `FluentMapper.Registry`. -- `FluentMapTypeMap` e `FluentConventionTypeMap` passam a delegar resolucao ao registry. - -Chave estruturada: - -```csharp -MappingCacheKey -{ - Type Type; - string ColumnName; - MappingCacheOptions Options; -} -``` - -`MappingCacheOptions` diferencia: - -- `FluentMap`: mapping explicito, convention e fallback posterior do Dapper; -- `ConventionOnly`: compatibilidade de `FluentConventionTypeMap`. - -Comparacao de coluna e case sensitivity: - -- a chave usa `ColumnName` com igualdade ordinal para diferenciar chamadas como `case_id` e `CASE_ID`; -- a decisao de match continua por `IPropertyMap.CaseSensitive`, preservando o comportamento atual; -- mudancas de configuracao invalidam as entradas do tipo afetado, entao alteracoes de case sensitivity por API nao reaproveitam resultados antigos. - -Invalidacao: - -- `AddEntityMap` invalida todas as entradas de cache do tipo e reinstala o type map composto no Dapper; -- `AddConvention(Type, Convention)` atualiza a lista de conventions, invalida o tipo e reinstala o type map composto; -- `Reset(params Type[])` limpa entity maps, conventions, cache e remove os type maps do Dapper para os tipos informados. - -Thread safety: - -- dicionarios globais continuam `ConcurrentDictionary`; -- o cache estruturado usa `ConcurrentDictionary`; -- misses sao cacheados como `MappingCacheEntry` com `PropertyInfo` nulo, evitando valor nulo direto no dicionario; -- conventions sao adicionadas por `AddOrUpdate` com copia da lista atual. - -Compatibilidade: - -- nenhuma API publica foi removida ou renomeada; -- `EntityMaps` e `TypeConventions` continuam campos publicos do mesmo tipo; -- `FluentConventionTypeMap` continua publico; -- `SqlMapper.GetTypeMap(typeof(T))` continua recebendo `FluentMapTypeMap` nos fluxos internos de configuracao; -- foi adicionado `InternalsVisibleTo("Dapper.FluentMap.Tests")` para validar registry e cache sem tornar membros publicos. - -## Delivery - -Implementacao: - -- adicionado `MappingRegistry` interno; -- adicionados `MappingCacheKey`, `MappingCacheOptions` e `MappingCacheStrategy`; -- `FluentMapper` passou a manter um registry interno e expor os dicionarios publicos como storage compatibilizado; -- `FluentMapConfiguration.AddMap` passou a registrar mappings pelo registry; -- `FluentConventionConfiguration` passou a registrar conventions pelo registry; -- `FluentMapTypeMap` passou a delegar resolucao composta ao registry; -- `FluentConventionTypeMap` passou a delegar resolucao convention-only ao registry; -- testes do core passaram a usar `FluentMapper.Reset(...)` interno; -- adicionado acesso interno ao assembly de testes. - -Testes adicionados em `MappingRegistryTests`: - -- cache hit para mesma chave estruturada; -- chaves distintas para tipos distintos; -- chaves distintas para nomes de coluna distintos; -- comportamento case-sensitive atual; -- reset/invalidacao de mapping cacheado; -- invalidacao de miss cacheado quando um mapping e registrado depois; -- leitura concorrente basica via type map do Dapper. - -## Validation - -Ambiente: - -- SDK: `10.0.302` -- test runner: VSTest com xUnit v2 -- projetos de teste: `netcoreapp3.1` -- `DOTNET_ROLL_FORWARD=Major` usado para executar testes `netcoreapp3.1` neste ambiente. - -Comandos executados: - -- `dotnet restore .\Dapper.FluentMap.sln` - - resultado: falhou por metadado corrompido no cache NuGet global (`microsoft.netcore.targets` / `.nupkg.metadata` com byte `0x00`). -- `NUGET_PACKAGES=%TEMP%\dfm-nuget-packages-registry dotnet restore .\Dapper.FluentMap.sln` - - resultado: sucesso. -- `NUGET_PACKAGES=%TEMP%\dfm-nuget-packages-registry dotnet build .\Dapper.FluentMap.sln --no-restore` - - resultado: sucesso, 0 warnings, 0 erros. -- `NUGET_PACKAGES=%TEMP%\dfm-nuget-packages-registry DOTNET_ROLL_FORWARD=Major dotnet test .\Dapper.FluentMap.sln --no-build` - - resultado: sucesso, 45 testes aprovados no core e 7 testes aprovados no Dommel. - -- `dotnet build .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release` - - resultado: sucesso, 0 warnings, 0 erros. -- `NUGET_PACKAGES=%TEMP%\dfm-nuget-packages-registry dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore` - - resultado: sucesso, 0 warnings, 0 erros. -- `NUGET_PACKAGES=%TEMP%\dfm-nuget-packages-registry DOTNET_ROLL_FORWARD=Major dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --no-build --filter "FullyQualifiedName~MappingRegistryTests"` - - resultado: sucesso, 7 testes aprovados. -- `NUGET_PACKAGES=%TEMP%\dfm-nuget-packages-registry DOTNET_ROLL_FORWARD=Major dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --no-build --filter "FullyQualifiedName~MappingCompositionTests|FullyQualifiedName~DapperIntegrationTests"` - - resultado: sucesso, 15 testes aprovados. -- `NUGET_PACKAGES=%TEMP%\dfm-nuget-packages-registry DOTNET_ROLL_FORWARD=Major dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build` - - resultado: sucesso, 45 testes aprovados no core e 7 testes aprovados no Dommel. - -Pack nao foi executado porque nao houve mudanca de empacotamento, metadados NuGet ou targets. - -## Encerramento Da Etapa 1 - -Capacidades estabilizadas: - -- parsing de expressoes por membro real da expression tree; -- composicao deterministica entre mappings explicitos, conventions e fallback do Dapper; -- baseline de integracao com materializacao real via Dapper; -- dono interno de mappings e cache estruturado com invalidacao definida. - -Dividas transferidas: - -- os campos publicos mutaveis permanecem por compatibilidade; -- Dommel ainda consome os dicionarios publicos diretamente; -- paralelismo da suite continua desabilitado; -- suporte a MemberPath, objetos aninhados e Value Objects permanece fora do escopo. diff --git a/docs/sdd/etapa-1/README.md b/docs/sdd/etapa-1/README.md deleted file mode 100644 index 5e26cdb..0000000 --- a/docs/sdd/etapa-1/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# Etapa 1 - -## Objetivo - -Corrigir e fortalecer pontos centrais da resolucao de mapeamentos do `Dapper.FluentMap`, preservando a API publica e o comportamento existente sempre que possivel. - -## Entregas Previstas - -1. ReflectionHelper -2. Composicao de mappings -3. Testes de integracao com Dapper -4. MappingRegistry e cache - -## Ordem Das Entregas - -As entregas devem ser executadas na ordem acima, pois cada uma produz contexto e decisoes que podem afetar a proxima. - -## Leitura Obrigatoria - -Antes de iniciar proximas tarefas desta etapa, leia todos os arquivos `.md` diretamente relacionados em `docs/sdd/etapa-1/`, principalmente este `README.md`, `status.md`, `decisions.md` e relatorios de entregas anteriores. - -## Escopo - -O escopo atual esta concentrado no projeto principal `Dapper.FluentMap`. - -`Dapper.FluentMap.Dommel` esta fora do escopo funcional desta etapa, salvo se uma mudanca comprovada no core exigir adaptacao explicita. diff --git a/docs/sdd/etapa-1/decisions.md b/docs/sdd/etapa-1/decisions.md deleted file mode 100644 index c75b050..0000000 --- a/docs/sdd/etapa-1/decisions.md +++ /dev/null @@ -1,36 +0,0 @@ -# Decisoes Da Etapa 1 - -Registre aqui apenas decisoes que afetem entregas posteriores. - -## ReflectionHelper - -- Expressoes de propriedade devem ser resolvidas pelo `MemberExpression.Member` produzido pela expression tree, sem nova busca por nome via reflection. -- APIs que recebem `Expression>` para mapeamento devem aceitar `Convert` gerado por boxing de value types. -- Expressoes que nao resolvem para propriedade devem falhar cedo com `ArgumentException`, em vez de produzir `null`, `InvalidCastException` ou depender de falhas indiretas. - -## Composicao De Mappings - -- A estrategia instalada pelo FluentMap deve resolver mappings explicitos antes de conventions e usar `DefaultTypeMap` do Dapper como fallback final. -- `AddMap(...)` e `AddConvention(...).ForEntity(...)` nao devem depender da ordem de registro para coexistirem no mesmo tipo. -- Mapping explicito para uma propriedade impede que conventions resolvam essa mesma propriedade, permitindo override explicito da convention. -- `FluentConventionTypeMap` permanece publico para compatibilidade, mas os fluxos internos de convention passam a instalar o type map composto. -- Registry e invalidacao completa de cache continuam deliberadamente adiados para a Entrega 4. - -## Testes De Integracao Com Dapper - -- A baseline de integracao usa SQLite in-memory via `Microsoft.Data.Sqlite` apenas no projeto de testes principal. -- Os testes de integracao devem validar materializacao observavel por `Dapper.Query`, nao detalhes internos de `ITypeMap`. -- Testes que alteram `FluentMapper` devem usar o reset interno definido na Entrega 4 para limpar registry, cache e type maps do Dapper dos tipos tocados. -- O paralelismo da suite permanece desabilitado porque `FluentMapper`, `SqlMapper.SetTypeMap` e os dicionarios publicos mutaveis ainda compartilham estado global. - -## MappingRegistry E Cache - -- `MappingRegistry` passa a ser o dono interno de entity maps, conventions, cache de propriedades e instalacao de type maps no Dapper. -- `FluentMapper.EntityMaps` e `FluentMapper.TypeConventions` permanecem publicos por compatibilidade, mas apontam para o storage do registry. -- O cache ativo de resolucao usa chave estruturada com tipo, nome de coluna ordinal e opcoes de estrategia (`FluentMap` ou `ConventionOnly`), substituindo as chaves por concatenacao de strings. -- Case sensitivity continua sendo propriedade de cada `IPropertyMap`; a chave diferencia o nome de coluna recebido e a invalidacao por tipo cobre mudancas de configuracao. -- Reconfiguracoes feitas pela API do FluentMap invalidam o cache do tipo afetado e reinstalam o type map composto no Dapper. -- O reset interno de testes limpa entity maps, conventions, cache e type maps do Dapper para os tipos informados. -- `SqlMapper.SetTypeMap` continua como estado global necessario porque e o contrato publico de extensibilidade do Dapper. -- O membro protegido legado `MultiTypeMap.TypePropertyMapCache` nao e mais usado pelo core, mas foi preservado para evitar quebra de compatibilidade. -- Etapa 2 deve tratar qualquer tentativa de reduzir a mutabilidade publica dos dicionarios como mudanca de compatibilidade planejada. diff --git a/docs/sdd/etapa-1/status.md b/docs/sdd/etapa-1/status.md deleted file mode 100644 index f2ab082..0000000 --- a/docs/sdd/etapa-1/status.md +++ /dev/null @@ -1,8 +0,0 @@ -# Status Da Etapa 1 - -| Entrega | Status | Commit | -|---|---|---| -| 01 - ReflectionHelper | Concluído | fix: resolve ambiguous property expressions | -| 02 - Composicao de mappings | Concluído | fix: compose explicit mappings and conventions | -| 03 - Testes de integracao | Concluído | test: add Dapper integration coverage | -| 04 - MappingRegistry e cache | Concluído | refactor: introduce mapping registry and structured cache keys | diff --git a/docs/sdd/etapa-2/01-member-path.md b/docs/sdd/etapa-2/01-member-path.md deleted file mode 100644 index 695cc1d..0000000 --- a/docs/sdd/etapa-2/01-member-path.md +++ /dev/null @@ -1,202 +0,0 @@ -# 01 - MemberPath - -## Specification - -Introduzir uma representacao interna robusta de caminho de membro para diferenciar propriedades que compartilham o mesmo nome terminal, como: - -```csharp -x => x.Rank.Level -x => x.Seniority.Level -``` - -Requisitos: - -- representar todos os membros do caminho; -- preservar ordem; -- fornecer igualdade e hashing consistentes; -- suportar caminhos simples e aninhados; -- aceitar `Convert` produzido por `Expression>`; -- preservar a API publica baseada em `PropertyInfo` terminal; -- nao implementar materializacao de objetos aninhados. - -## Discovery - -Arquivos analisados: - -- `docs/sdd/etapa-1/README.md` -- `docs/sdd/etapa-1/status.md` -- `docs/sdd/etapa-1/decisions.md` -- `docs/sdd/etapa-1/04-mapping-registry-cache.md` -- `src/Dapper.FluentMap/Mapping/EntityMap.cs` -- `src/Dapper.FluentMap/Mapping/PropertyMap.cs` -- `src/Dapper.FluentMap/Utils/ReflectionHelper.cs` -- `src/Dapper.FluentMap/MappingRegistry.cs` -- `src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs` -- `test/Dapper.FluentMap.Tests/ManualMappingTests.cs` -- `test/Dapper.FluentMap.Tests/ReflectionHelperTests.cs` -- `test/Dapper.FluentMap.Tests/MappingCompositionTests.cs` -- `test/Dapper.FluentMap.Tests/MappingRegistryTests.cs` -- `test/Dapper.FluentMap.Tests/DapperIntegrationTests.cs` - -Achados: - -- `ReflectionHelper.GetMemberInfo` ja usa o `MemberExpression.Member` real da expression tree e aceita `Convert`, conforme decisao da Etapa 1. -- `ReflectionHelper.GetMemberInfo` retorna somente o membro terminal, perdendo a cadeia de acesso. -- `EntityMapBase.ThrowIfDuplicateMapping` detecta duplicidade por `p.PropertyInfo.Name == map.PropertyInfo.Name`. -- `MappingRegistry.IsExplicitlyMapped` tambem usa somente `PropertyInfo.Name` para impedir que conventions resolvam propriedades explicitamente mapeadas. -- `PropertyMap` preserva apenas `PropertyInfo`, `ColumnName`, `CaseSensitive` e `Ignored`; nao existe identidade interna de caminho. -- O cache atual (`MappingCacheKey`) usa tipo, coluna e estrategia; ele nao colide por caminho de propriedade, mas armazena apenas o `PropertyInfo` terminal resolvido. -- Conventions escaneiam propriedades publicas de instancia do tipo raiz e produzem mapas simples. - -Comportamento atual confirmado por leitura do fluxo e por teste de regressao executado antes da implementacao: - -- `Map(x => x.Rank.Level)` cria um `PropertyMap` cujo `PropertyInfo.Name` e `Level`. -- `Map(x => x.Seniority.Level)` cria outro `PropertyMap` cujo `PropertyInfo.Name` tambem e `Level`. -- A segunda chamada falha durante configuracao em `EntityMapBase.ThrowIfDuplicateMapping`, antes de cache ou materializacao. -- Comando de confirmacao: - - `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~PropertyMapShouldDistinguishNestedPropertiesWithSameTerminalName"` - - resultado antes da correcao: falha com `Duplicate mapping detected. Property 'Level' is already mapped to column 'Level'.` - -## Decision - -Representacao escolhida: - -```text -internal sealed class MemberPath -``` - -Semantica: - -- armazena uma sequencia ordenada de `PropertyInfo`; -- o membro terminal continua disponivel como `PropertyInfo`; -- caminho simples: uma propriedade, por exemplo `Name`; -- caminho aninhado: duas ou mais propriedades, por exemplo `Address.City`; -- `ToString()` retorna a string de diagnostico formada por nomes unidos por `.`; -- igualdade compara a sequencia completa de propriedades por identidade de membro; -- hashing combina todos os membros na mesma ordem; -- `Convert` e removido durante o parsing da expression; -- expressoes que nao resolvem para cadeia de propriedades continuam falhando com `ArgumentException`; -- indexers e chamadas de metodo permanecem invalidos. - -Compatibilidade: - -- `IPropertyMap.PropertyInfo` nao sera removido nem alterado; -- `PropertyMap.PropertyInfo` continua sendo o terminal; -- `MemberPath` sera interno e associado aos mapas produzidos pelo core; -- implementacoes externas de `IPropertyMap` que nao conhecem `MemberPath` recebem fallback para caminho simples baseado em `PropertyInfo`. - -Limite arquitetural: - -- esta entrega nao cria materializador aninhado; -- retornar o `PropertyInfo` terminal para o Dapper continua sendo o limite do `ITypeMap` atual. - -## Delivery - -Arquivos alterados: - -- `src/Dapper.FluentMap/Mapping/MemberPath.cs` -- `src/Dapper.FluentMap/Mapping/PropertyMapIdentity.cs` -- `src/Dapper.FluentMap/Utils/ReflectionHelper.cs` -- `src/Dapper.FluentMap/Mapping/PropertyMap.cs` -- `src/Dapper.FluentMap/Mapping/EntityMap.cs` -- `src/Dapper.FluentMap/MappingRegistry.cs` -- `test/Dapper.FluentMap.Tests/MemberPathTests.cs` -- `test/Dapper.FluentMap.Tests/ManualMappingTests.cs` -- `test/Dapper.FluentMap.Tests/MappingCompositionTests.cs` - -Modelo anterior: - -- `ReflectionHelper` resolvia o membro correto, mas devolvia apenas o `PropertyInfo` terminal. -- `PropertyMap` armazenava apenas o terminal. -- Duplicidade e override de convention eram decididos por `PropertyInfo.Name`. - -Modelo novo: - -- `MemberPath` e uma representacao interna imutavel baseada em uma sequencia ordenada de `PropertyInfo`. -- `ReflectionHelper.GetMemberPath` percorre a cadeia da expression, remove `Convert`/`ConvertChecked` e valida que cada elo e propriedade. -- `ReflectionHelper.GetMemberInfo` continua publico e passa a devolver o terminal do `MemberPath`, preservando contrato. -- `PropertyMapBase` guarda `MemberPath` internamente, mantendo `PropertyInfo` publico como terminal. -- `PropertyMapIdentity` centraliza leitura/escrita da identidade interna e fornece fallback para caminho simples quando uma implementacao externa de `IPropertyMap` nao carrega `MemberPath`. -- `EntityMapBase.ThrowIfDuplicateMapping` compara caminhos completos. -- `MappingRegistry.IsExplicitlyMapped` compara caminhos completos para evitar que `Rank.Level` bloqueie uma convention para `Level` no tipo raiz. - -Igualdade: - -- dois `MemberPath` sao iguais quando possuem a mesma quantidade de propriedades e cada posicao representa o mesmo membro. -- a comparacao usa `Module`, `MetadataToken` e `DeclaringType` quando disponiveis, com fallback para `PropertyInfo.Equals`. -- a igualdade considera ordem, entao `Rank.Level` e diferente de `Seniority.Level`. - -Hashing: - -- o hash combina todos os membros do caminho em ordem. -- cada membro usa a mesma identidade por metadados usada na igualdade quando disponivel, com fallback para `PropertyInfo.GetHashCode`. - -Impacto nos caches: - -- `MappingCacheKey` nao mudou: continua usando tipo, nome de coluna ordinal e estrategia. -- o cache ainda retorna `PropertyInfo` terminal porque este e o contrato exigido pelo `CustomPropertyTypeMap` do Dapper. -- a correcao de identidade ocorre antes da entrada no cache, na configuracao e na composicao explicito/convention. - -Testes adicionados: - -- caminho simples (`Name`); -- caminho aninhado (`Address.City`); -- caminhos distintos com terminal igual (`Rank.Level` e `Seniority.Level`); -- igualdade/hash para o mesmo caminho; -- `Convert` em value type; -- expression invalida; -- dois nested mappings com terminal `Level` devem coexistir; -- duplicidade real do mesmo nested path deve continuar falhando; -- explicit mapping aninhado nao deve bloquear convention de propriedade raiz com mesmo terminal. - -Nao suportado nesta entrega: - -- materializacao de objetos aninhados; -- criacao automatica de objetos intermediarios; -- Value Objects ponta a ponta; -- custom materializer; -- source generator; -- query wrapper; -- naming policy baseada em caminho completo. - -## Validation - -Ambiente: - -- SDK: `10.0.302` -- test runner: VSTest com xUnit v3 -- projeto principal: `netstandard2.0` -- projetos de teste: `net10.0` -- `NUGET_PACKAGES=%TEMP%\dfm-nuget-packages-memberpath` usado para isolar o cache NuGet. - -Comandos executados: - -- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~PropertyMapShouldDistinguishNestedPropertiesWithSameTerminalName"` - - resultado antes da correcao: falhou reproduzindo a duplicidade por `Level`. -- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~MemberPathTests|FullyQualifiedName~PropertyMapShouldDistinguishNestedPropertiesWithSameTerminalName|FullyQualifiedName~DuplicateNestedPropertyPathShouldThrow|FullyQualifiedName~ExplicitNestedMappingShouldNotOverrideConvention"` - - resultado: sucesso, 9 testes aprovados. -- `dotnet restore .\Dapper.FluentMap.sln` - - resultado: sucesso. -- `dotnet build .\Dapper.FluentMap.sln --no-restore` - - resultado: sucesso, 0 warnings, 0 erros. -- `dotnet test .\Dapper.FluentMap.sln --no-build` - - resultado: sucesso, 54 testes aprovados no core e 7 testes aprovados no Dommel. -- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --no-build` - - resultado: sucesso, 54 testes aprovados. -- `dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore` - - resultado: sucesso, 0 warnings, 0 erros. -- `dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build` - - resultado: sucesso, 54 testes aprovados no core e 7 testes aprovados no Dommel. -- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --no-build` - - resultado: sucesso, 54 testes aprovados. - -Confirmacoes: - -- testes da Etapa 1 continuam passando; -- `MemberPath` diferencia `Rank.Level` de `Seniority.Level`; -- duplicidade real do mesmo caminho continua sendo detectada; -- nao houve implementacao de nested materialization; -- Dommel nao recebeu alteracao funcional; -- API publica existente foi preservada. - -Pack nao foi executado porque nao houve mudanca de empacotamento, metadados NuGet ou targets. diff --git a/docs/sdd/etapa-2/02-configuration-validation.md b/docs/sdd/etapa-2/02-configuration-validation.md deleted file mode 100644 index 8ee023e..0000000 --- a/docs/sdd/etapa-2/02-configuration-validation.md +++ /dev/null @@ -1,216 +0,0 @@ -# 02 - Validacao E Diagnosticos De Configuracao - -## Specification - -Adicionar validacoes estruturadas para configuracoes invalidas e melhorar diagnosticos de erro, preservando configuracoes validas existentes e sem ampliar o escopo funcional do core para materializacao aninhada, ORM ou query builder. - -Casos priorizados: - -- mesma propriedade ou mesmo `MemberPath` mapeado mais de uma vez; -- caminhos distintos com o mesmo nome terminal; -- coluna duplicada quando a resolucao seria ambigua; -- expression invalida; -- convention ambigua; -- incoerencia de case sensitivity; -- metadata de propriedade incompativel com a entidade; -- pontos que lancavam `Exception` generica. - -## Discovery - -Arquivos analisados: - -- `docs/sdd/etapa-1/README.md` -- `docs/sdd/etapa-1/decisions.md` -- `docs/sdd/etapa-2/README.md` -- `docs/sdd/etapa-2/status.md` -- `docs/sdd/etapa-2/decisions.md` -- `docs/sdd/etapa-2/01-member-path.md` -- `src/Dapper.FluentMap/Mapping/EntityMap.cs` -- `src/Dapper.FluentMap/Mapping/PropertyMap.cs` -- `src/Dapper.FluentMap/Mapping/MemberPath.cs` -- `src/Dapper.FluentMap/Mapping/PropertyMapIdentity.cs` -- `src/Dapper.FluentMap/MappingRegistry.cs` -- `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs` -- `src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs` -- `src/Dapper.FluentMap/Conventions/Convention.cs` -- `src/Dapper.FluentMap/Conventions/PropertyConventionConfiguration.cs` -- `src/Dapper.FluentMap/Conventions/ConventionPropertyConfiguration.cs` -- `src/Dapper.FluentMap/Utils/ReflectionHelper.cs` -- `src/Dapper.FluentMap/Utils/FluentMapConfigurationExtensions.cs` -- testes existentes do core diretamente relacionados. - -Catalogo encontrado: - -| Condicao | Excecao anterior | Momento anterior | Classificacao | Detectavel antes | -|---|---|---|---|---| -| Mesmo mapping chamado duas vezes para a mesma propriedade simples | `Exception` generica | construcao do `EntityMap` | erro de configuracao | sim | -| Mesmo `MemberPath` aninhado chamado duas vezes | `Exception` generica | construcao do `EntityMap` | erro de configuracao | sim | -| Dois paths distintos com mesmo terminal, como `Rank.Level` e `Seniority.Level` | falhava antes da Entrega 01; agora valido | configuracao | configuracao valida | sim | -| Dois maps explicitos do core para a mesma coluna | sem erro; primeiro match vencia | resolucao/materializacao | erro de configuracao | sim, no registro do map | -| Dois maps explicitos do core com colunas que colidem por case sensitivity | resultado dependia de ordem e coluna consultada | resolucao/materializacao | erro de configuracao | sim, no registro do map | -| Duas propriedades de uma convention resolvendo para a mesma coluna | `Exception` generica | `GetMember`/materializacao | erro de configuracao | sim, ao registrar a convention | -| Convention sem `Configure(...)` para regra aplicavel | `NullReferenceException` indireta | configuracao de convention | erro de configuracao | sim | -| `Map(...)` com expression que nao e caminho de propriedade | `ArgumentException` | construcao do `EntityMap` | erro imediato | sim | -| Expression nula | `ArgumentNullException` | helper de reflection | erro imediato | sim | -| Predicate/configure/transformer nulos em convention | falhas indiretas ou comportamento silencioso | configuracao | erro imediato de argumento | sim | -| `ToColumn(null)` ou `ToColumn("")` | mapeamento inutil/diagnostico tardio | configuracao | erro imediato de argumento | sim | -| `PropertyMap` sem `PropertyInfo` | `NullReferenceException` no construtor ou falha indireta | configuracao | erro imediato de argumento | sim | -| `IEntityMap` customizado com `PropertyInfo` de outro tipo | sem erro estruturado | resolucao/materializacao | erro de configuracao | sim, no registro do map | -| Registro duplicado de `EntityMap` para a mesma entidade | `InvalidOperationException` | `AddMap` | erro de configuracao | sim | -| `IgnoredPropertyInfo` com membros nao implementados | `NotImplementedException` | uso indevido do sentinel interno | erro de runtime fora do fluxo esperado | parcialmente; fora de escopo | -| Falhas de `FluentMapConfigurationExtensions` ao refletir maps de assemblies | `InvalidOperationException` | apply por assembly | diagnostico de discovery/reflection | parcialmente; fora de escopo funcional desta entrega | - -## Decision - -Nao foi adicionada API publica `Validate()`. - -Motivo: - -- os casos prioritarios encontrados sao deterministas e podem falhar cedo durante a configuracao; -- nao ha, nesta entrega, warnings agregaveis que justifiquem um contrato publico novo; -- uma API publica de diagnostico agregado exigiria definir modelo de resultado, estabilidade de mensagens, escopo de warning e interacao com estado global, o que pertence a uma evolucao posterior. - -Foi adicionada uma excecao publica: - -```text -FluentMapConfigurationException : InvalidOperationException -``` - -Motivo: - -- diferencia erros de configuracao do FluentMap de falhas arbitrarias de runtime; -- preserva compatibilidade razoavel para fluxos que ja tratavam `InvalidOperationException`; -- substitui usos de `Exception` generica em erros de configuracao controlados; -- evita hierarquia extensa. - -Classificacao das regras: - -| Regra | Decisao | -|---|---| -| `MemberPath` duplicado no mesmo `EntityMap` | erro imediato | -| registro duplicado de `EntityMap` para a mesma entidade | erro imediato | -| coluna duplicada em mappings explicitos do core da mesma entidade | erro imediato no `AddMap` | -| conflito de coluna por case sensitivity em mappings explicitos do core | erro imediato no `AddMap` | -| convention ambigua para a mesma entidade | erro imediato no registro da convention | -| expression invalida | erro imediato com `ArgumentException` | -| argumentos nulos/coluna vazia | `ArgumentNullException` ou `ArgumentException` | -| metadata de propriedade incompativel com entidade | erro imediato no `AddMap` | -| conflitos entre explicit mapping e convention para mesma coluna | fora de escopo como erro; precedencia explicita continua preservada | -| materializacao aninhada | fora de escopo | - -Formato de mensagens: - -- incluir entidade sempre que o erro for por entidade; -- incluir `MemberPath.ToString()` para caminhos; -- incluir coluna quando o conflito envolver coluna; -- incluir tipo da convention ou entity map quando a origem ajudar; -- nao depender de mensagens internas de reflection ou Dapper para explicar erros do FluentMap. - -## Delivery - -Arquivos adicionados: - -- `src/Dapper.FluentMap/FluentMapConfigurationException.cs` -- `src/Dapper.FluentMap/MappingConfigurationValidator.cs` -- `test/Dapper.FluentMap.Tests/ConfigurationValidationTests.cs` - -Arquivos alterados: - -- `src/Dapper.FluentMap/Mapping/EntityMap.cs` -- `src/Dapper.FluentMap/Mapping/PropertyMap.cs` -- `src/Dapper.FluentMap/MappingRegistry.cs` -- `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs` -- `src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs` -- `src/Dapper.FluentMap/Conventions/PropertyConventionConfiguration.cs` -- `src/Dapper.FluentMap/Conventions/ConventionPropertyConfiguration.cs` -- `test/Dapper.FluentMap.Tests/ManualMappingTests.cs` - -Implementacao: - -- `EntityMapBase.Map(...)` continua falhando cedo para `MemberPath` duplicado, agora com `FluentMapConfigurationException` e mensagem com entidade, path e colunas. -- `MappingConfigurationValidator` valida entity maps antes do registro global e conventions antes de instala-las no registry. -- conflitos de coluna sao detectados quando duas configuracoes do core podem responder pela mesma coluna, incluindo sobreposicao por case-insensitive. -- conventions continuam usando o mesmo criterio de pertencimento da resolucao existente: `ReflectedType` no target atual, com alternativa `DeclaringType` para `NETSTANDARD1_3`. -- argumentos nulos e colunas vazias em APIs fluentes agora falham com excecoes padrao de argumento. -- `ReflectionHelper` manteve `ArgumentException` para expressions invalidas; as mensagens existentes ja indicam que a expression deve resolver para um property path. - -## Compatibility - -API publica adicionada: - -- `Dapper.FluentMap.FluentMapConfigurationException`. - -API publica nao adicionada: - -- nenhum `FluentMapper.Validate()`; -- nenhum `configuration.Validate()`; -- nenhum `Explain()`. - -Comportamento preservado: - -- configuracoes validas continuam validas; -- paths distintos com mesmo nome terminal continuam coexistindo; -- extensoes de `IPropertyMap`, como Dommel, podem reutilizar coluna quando possuem semantica adicional propria; -- composicao explicit mapping -> convention -> Dapper default permanece; -- Dommel nao recebeu alteracao funcional; -- `PropertyInfo` publico segue sendo o membro terminal. - -Comportamento alterado somente para configuracoes invalidas: - -- duplicidades e ambiguidades passam a falhar cedo com diagnostico estruturado; -- alguns argumentos invalidos passam a falhar imediatamente, em vez de produzir erro indireto ou mapping inutil. - -## Tests - -Testes adicionados cobrem: - -- configuracao valida; -- `MemberPath` duplicado; -- paths distintos com mesmo nome terminal; -- registro duplicado de map; -- conflito explicito de coluna; -- conflito de coluna por case sensitivity; -- reutilizacao de coluna por `IPropertyMap` externo, preservando compatibilidade com extensoes; -- convention ambigua; -- expression invalida; -- mensagem com contexto util; -- metadata incompativel; -- convention sem `Configure(...)`. - -Como nao houve API `Validate()`, nao ha teste de validacao repetida. - -## Validation - -Comandos executados durante a entrega: - -- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~ConfigurationValidationTests"` - - resultado: sucesso, 10 testes aprovados. -- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj` - - resultado inicial: falha em `ConventionTests.ShouldMapEntitiesInAssembly` porque a validacao de convention usava compatibilidade por `DeclaringType` e classificava mapas herdados de outros tipos como duplicados. -- correcao: a validacao de convention passou a usar o mesmo filtro da resolucao (`ReflectedType == type` em `netstandard2.0`). -- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj` - - resultado: sucesso, 64 testes aprovados. -- `dotnet restore` - - resultado: sucesso. -- `dotnet build` - - resultado: sucesso, 0 warnings, 0 erros. -- `dotnet test` - - resultado inicial: falha em 3 testes do Dommel porque a regra de coluna duplicada no core tambem atingia `DommelPropertyMap`, onde a reutilizacao de coluna possui semantica adicional valida. -- correcao: a validacao de conflito de coluna foi limitada a `PropertyMap` do core e foi adicionado teste de compatibilidade para `IPropertyMap` externo. -- `dotnet test` - - resultado: sucesso, 65 testes aprovados no core e 7 testes aprovados no Dommel. -- `dotnet build --configuration Release` - - resultado: sucesso, 0 warnings, 0 erros. -- `dotnet test --configuration Release` - - resultado: sucesso, 65 testes aprovados no core e 7 testes aprovados no Dommel. - -Pack nao foi executado porque nao houve mudanca de empacotamento, metadados NuGet ou targets. - -## Limitacoes - -- Nao ha diagnostico agregado ou warnings; erros sao fail-fast. -- Conflitos de coluna em implementacoes externas de `IPropertyMap` nao sao tratados como erro pelo core, pois extensoes como Dommel podem atribuir semantica adicional a maps com a mesma coluna. -- Conflitos entre mapping explicito e convention para a mesma coluna permanecem governados pela precedencia existente e nao sao tratados como erro nesta entrega. -- O sentinel interno `IgnoredPropertyInfo` continua fora do escopo. -- `FluentMapConfigurationExtensions.ApplyMapsFromAssemblies` ainda possui diagnosticos proprios de discovery/reflection e nao foi redesenhado. -- Nao foi implementado suporte a materializacao aninhada. diff --git a/docs/sdd/etapa-2/03-inherited-mappings.md b/docs/sdd/etapa-2/03-inherited-mappings.md deleted file mode 100644 index fd1209f..0000000 --- a/docs/sdd/etapa-2/03-inherited-mappings.md +++ /dev/null @@ -1,255 +0,0 @@ -# 03 - Heranca De Mappings - -## Specification - -Adicionar suporte explicito para reutilizar mappings configurados em uma classe base quando um mapping de tipo derivado optar por essa composicao. - -Problema historico: - -- um `EntityMap` configurado para `User.Id -> user_id` nao era aplicado a `AdminUser : User`; -- consumidores precisavam copiar mappings herdados para cada tipo derivado; -- inferir heranca automaticamente poderia alterar comportamento existente de forma silenciosa. - -Requisitos tratados: - -- inclusao deliberada de base mapping; -- ordem de composicao; -- precedencia entre derivado, base, convention e Dapper default; -- override de membro herdado; -- conflito de coluna entre base e derivado; -- interacao com conventions; -- preservacao de `MemberPath` herdado; -- hierarquia invalida; -- multiplos niveis de heranca; -- ordem de registro diagnostica. - -## Discovery - -Arquivos analisados: - -- `AGENTS.md` -- `docs/sdd/etapa-1/README.md` -- `docs/sdd/etapa-1/decisions.md` -- `docs/sdd/etapa-1/04-mapping-registry-cache.md` -- `docs/sdd/etapa-2/README.md` -- `docs/sdd/etapa-2/status.md` -- `docs/sdd/etapa-2/decisions.md` -- `docs/sdd/etapa-2/01-member-path.md` -- `docs/sdd/etapa-2/02-configuration-validation.md` -- `src/Dapper.FluentMap/Mapping/EntityMap.cs` -- `src/Dapper.FluentMap/Mapping/PropertyMap.cs` -- `src/Dapper.FluentMap/Mapping/MemberPath.cs` -- `src/Dapper.FluentMap/Mapping/PropertyMapIdentity.cs` -- `src/Dapper.FluentMap/MappingRegistry.cs` -- `src/Dapper.FluentMap/MappingConfigurationValidator.cs` -- `src/Dapper.FluentMap/TypeMaps/FluentTypeMap.cs` -- `src/Dapper.FluentMap/TypeMaps/FluentConventionTypeMap.cs` -- `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs` -- `src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs` -- testes do core relacionados a composicao, validacao, registry e Dapper. - -Achados: - -- `MappingRegistry.ResolveFluentPropertyInfo` ja centralizava a precedencia explicito -> convention -> Dapper default. -- `GetExplicitPropertyMaps(type)` retornava apenas o `EntityMap` registrado para o tipo exato. -- `MemberPath` ja permitia comparar propriedades herdadas por identidade de membro, nao apenas nome terminal. -- `MappingConfigurationValidator` ja validava compatibilidade de paths cujo primeiro membro vem de classe base. -- conventions para um tipo derivado ja enxergavam propriedades herdadas via `type.GetProperties(...)`. -- nao existia metadado no `EntityMap` para declarar que um map derivado depende de um map base. - -Reproducao inicial: - -- foi adicionado um teste expressando `IncludeBase()`; -- antes da implementacao, a suite falhava na compilacao com `CS0103`, pois a API nao existia; -- isso confirmou que o suporte precisava de contrato publico/protegido novo, nao apenas ajuste de registry. - -## Decision - -API escolhida: - -```csharp -protected void IncludeBase() - where TBase : class -``` - -Uso: - -```csharp -public class UserMap : EntityMap -{ - public UserMap() - { - Map(e => e.Id).ToColumn("user_id"); - } -} - -public class AdminUserMap : EntityMap -{ - public AdminUserMap() - { - IncludeBase(); - Map(e => e.Permission).ToColumn("admin_permission"); - } -} -``` - -Motivos: - -- inclusao deliberada, sem heranca magica por reflection; -- baixa complexidade; -- preserva API publica existente e adiciona apenas uma API protegida para autores de maps; -- evita profiles, modos de heranca ou scanning amplo; -- permite diagnostico claro quando a base nao foi registrada. - -Resolucao do mapping base: - -- `IncludeBase()` armazena internamente o tipo base no `EntityMap` derivado; -- o `MappingRegistry` resolve o `IEntityMap` base ja registrado para esse tipo; -- o base map deve ser registrado antes do derived map; -- se o base map nao existir, `AddMap(derived)` falha com `FluentMapConfigurationException`. - -Modelo de composicao: - -```text -maps proprios do derivado -maps explicitos da base incluida, ja compostos recursivamente -``` - -Para multiplos niveis: - -```text -Derived - IncludeBase() - -Intermediate - IncludeBase() -``` - -O resultado efetivo para `Derived` e: - -```text -Derived explicit maps -Intermediate explicit maps -Base explicit maps -``` - -Precedencia final: - -```text -Mapping explicito do derivado - ↓ -Mapping explicito herdado mais proximo - ↓ -Mapping explicito herdado mais distante - ↓ -Convention do tipo consultado - ↓ -Dapper Default -``` - -Overrides: - -- se derivado e base configurarem o mesmo `MemberPath`, o mapping do derivado vence; -- o mapping base sobrescrito nao participa da resolucao de coluna para o tipo derivado; -- a comparacao de override usa `MemberPath`, preservando membros herdados e caminhos aninhados. - -Conflitos: - -- se derivado e base configurarem a mesma coluna para `MemberPath` diferentes, a configuracao do derivado falha cedo; -- conflito respeita case sensitivity pelas regras da Entrega 02; -- conflito real entre maps do core continua sendo `FluentMapConfigurationException`. - -Conventions: - -- conventions continuam registradas por tipo; -- mappings explicitos compostos, incluindo herdados, bloqueiam convention para o mesmo `MemberPath`; -- convention ainda pode resolver propriedades distintas do derivado. - -Validacoes: - -- `TBase` deve ser uma classe base real de `TEntity`; -- incluir o mesmo base type mais de uma vez e invalido; -- base map ausente e invalido no registro do map derivado; -- derived antes de base e invalido, mas pode ser tentado novamente depois que a base for registrada; -- coluna duplicada entre derivado e base e invalida; -- `MemberPath` incompativel continua invalido pela validacao existente. - -## Delivery - -Arquivos alterados: - -- `src/Dapper.FluentMap/Mapping/EntityMap.cs` -- `src/Dapper.FluentMap/MappingRegistry.cs` -- `src/Dapper.FluentMap/MappingConfigurationValidator.cs` -- `test/Dapper.FluentMap.Tests/MappingCompositionTests.cs` -- `test/Dapper.FluentMap.Tests/InheritedMappingTests.cs` -- `docs/sdd/etapa-2/status.md` -- `docs/sdd/etapa-2/decisions.md` -- `docs/sdd/etapa-2/03-inherited-mappings.md` - -Implementacao: - -- adicionado metadado interno `IEntityMapWithIncludedBaseTypes`; -- `EntityMapBase` passou a registrar bases incluidas; -- `IncludeBase()` valida relacao de heranca e duplicidade; -- `MappingRegistry` passou a compor explicit maps do tipo consultado com mapas base incluidos; -- composicao recursiva ignora paths ja definidos pelo tipo mais derivado, implementando override; -- validacao composta detecta conflitos de coluna depois da aplicacao dos overrides. - -Nao implementado: - -- heranca automatica sem `IncludeBase()`; -- multiplos modos de heranca; -- profiles; -- compartilhamento entre tipos nao relacionados; -- suporte novo a materializacao aninhada; -- alteracao funcional no Dommel. - -## Tests - -Testes adicionados cobrem: - -- base mapping simples; -- derived adicionando propriedade propria; -- derived sobrescrevendo mapping base; -- mapping base com convention no derived; -- `MemberPath` herdado e aninhado; -- multiplos niveis de heranca; -- base map inexistente; -- tipo informado que nao e base valido; -- conflito de coluna entre derived e base; -- ordem de registro base antes de derived; -- materializacao real com Dapper e SQLite in-memory. - -## Validation - -Comandos executados durante a entrega: - -- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~IncludedBaseMappingShouldResolveColumnForDerivedEntity"` - - antes da implementacao: falha de compilacao `CS0103` porque `IncludeBase` nao existia; - - depois da implementacao: sucesso, 1 teste aprovado. -- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~InheritedMappingTests"` - - resultado: sucesso, 11 testes aprovados. -- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj` - - resultado: sucesso, 77 testes aprovados. - -- `dotnet restore` - - resultado: sucesso. -- `dotnet build` - - resultado: sucesso, 0 warnings, 0 erros. -- `dotnet test` - - resultado: sucesso, 77 testes aprovados no core e 7 testes aprovados no Dommel. -- `dotnet build --configuration Release` - - resultado: sucesso, 0 warnings, 0 erros. -- `dotnet test --configuration Release` - - resultado: sucesso, 77 testes aprovados no core e 7 testes aprovados no Dommel. - -Pack nao e esperado porque nao houve mudanca de empacotamento, metadados NuGet ou targets. - -## Limitacoes - -- a base deve ser registrada antes do derivado; -- mutacoes diretas nos dicionarios publicos legados continuam fora do modelo de invalidacao segura; -- o suporte inclui apenas explicit mappings de base, nao conventions registradas para o tipo base; -- `IncludeBase()` aceita apenas classe base real, nao interface; -- materializacao aninhada permanece fora do escopo. diff --git a/docs/sdd/etapa-2/04-naming-policies.md b/docs/sdd/etapa-2/04-naming-policies.md deleted file mode 100644 index 78cab5e..0000000 --- a/docs/sdd/etapa-2/04-naming-policies.md +++ /dev/null @@ -1,262 +0,0 @@ -# 04 - Naming Policies - -## Specification - -Adicionar uma API clara e reutilizavel para transformar nomes de membros em nomes de colunas sem criar um segundo pipeline de conventions. - -Casos tratados: - -- `CustomerId -> customer_id`; -- `FirstName -> first_name`; -- `Id -> customer_id`; -- `Name -> usr_name`; -- prefix; -- suffix; -- transformacao customizada; -- composicao com mappings explicitos, mappings herdados, conventions e fallback do Dapper. - -Fora do objetivo: - -- reproduzir apenas `DefaultTypeMap.MatchNamesWithUnderscores`; -- criar dezenas de estilos de nomes; -- introduzir profiles; -- alterar estado global do Dapper como efeito colateral; -- declarar suporte a materializacao aninhada. - -## Discovery - -Arquivos analisados: - -- `AGENTS.md` -- `docs/sdd/etapa-1/README.md` -- `docs/sdd/etapa-1/decisions.md` -- `docs/sdd/etapa-1/04-mapping-registry-cache.md` -- `docs/sdd/etapa-2/README.md` -- `docs/sdd/etapa-2/status.md` -- `docs/sdd/etapa-2/decisions.md` -- `docs/sdd/etapa-2/01-member-path.md` -- `docs/sdd/etapa-2/02-configuration-validation.md` -- `docs/sdd/etapa-2/03-inherited-mappings.md` -- `README.md` -- `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs` -- `src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs` -- `src/Dapper.FluentMap/Conventions/Convention.cs` -- `src/Dapper.FluentMap/Conventions/PropertyConventionConfiguration.cs` -- `src/Dapper.FluentMap/Conventions/ConventionPropertyConfiguration.cs` -- `src/Dapper.FluentMap/MappingRegistry.cs` -- `src/Dapper.FluentMap/MappingConfigurationValidator.cs` -- testes de composition, inherited mappings, registry e Dapper integration. - -Recursos existentes: - -- `Convention` ja registra `PropertyMap` por entidade. -- `ConventionPropertyConfiguration.HasPrefix(...)` ja permite prefix simples. -- `ConventionPropertyConfiguration.Transform(Func)` ja permite transformacao customizada. -- `ConventionPropertyConfiguration.IsCaseInsensitive()` ja governa comparacao de coluna. -- `FluentConventionConfiguration` ja faz scanning das propriedades, cria `PropertyMap` e chama a validacao. -- `MappingRegistry` ja centraliza explicit mappings, conventions, fallback, cache e instalacao de type maps no Dapper. - -Limitacoes encontradas: - -- para usar uma transformacao simples, o consumidor precisava criar uma classe `Convention` dedicada; -- nao havia built-in para snake_case; -- nao havia built-in direto para suffix; -- prefix e transformacao existiam, mas nao havia um modelo declarativo e composavel de policy; -- o suporte nativo `DefaultTypeMap.MatchNamesWithUnderscores` e um flag estatico global do Dapper e cobre apenas matching underscore, sem prefix, suffix ou custom transform. - -Comportamento nativo do Dapper verificado: - -- `DefaultTypeMap.MatchNamesWithUnderscores = false` nao mapeia `customer_id` para `CustomerId` pelo `DefaultTypeMap`; -- `DefaultTypeMap.MatchNamesWithUnderscores = true` passa a mapear `customer_id` para `CustomerId`; -- o flag e global e foi restaurado no teste; -- a nova API nao altera esse flag. - -## Decision - -Modelo escolhido: - -```csharp -public sealed class NamingPolicy -``` - -com API: - -```csharp -NamingPolicy.Identity -NamingPolicy.SnakeCase -NamingPolicy.Prefix(string prefix) -NamingPolicy.Suffix(string suffix) -NamingPolicy.Custom(Func transformer) - -policy.Then(...) -policy.WithPrefix(...) -policy.WithSuffix(...) -policy.GetColumnName(...) -``` - -Registro: - -```csharp -FluentMapper.Initialize(c => -{ - c.UseNamingPolicy(NamingPolicy.SnakeCase) - .ForEntity(); - - c.UseNamingPolicy(NamingPolicy.SnakeCase.WithPrefix("usr_")) - .ForEntity(); -}); -``` - -Custom: - -```csharp -c.UseNamingPolicy(name => "x_" + name.ToLowerInvariant()) - .ForEntity(); -``` - -Motivos: - -- um delegate e suficiente para a execucao; -- uma classe pequena permite built-ins e composicao sem introduzir interface publica prematura; -- `Func` preserva o mesmo nivel funcional que a convention atual; -- `MemberPath` nao foi exposto na API porque conventions atuais operam sobre propriedades simples do tipo consultado e a etapa nao implementa materializacao aninhada; -- futuras etapas podem adicionar overload baseado em caminho se houver suporte real ponta a ponta. - -Integracao com conventions: - -- `UseNamingPolicy(...)` cria uma convention interna (`NamingPolicyConvention`); -- a convention interna usa `Properties().Configure(c => c.Transform(...))`; -- `UseNamingPolicy(...)` retorna `FluentConventionConfiguration`, portanto usa os mesmos `.ForEntity()`, `.ForEntitiesInAssembly(...)` e `.ForEntitiesInCurrentAssembly(...)`; -- nao ha storage global novo fora do `MappingRegistry`; -- nao ha alteracao silenciosa em `DefaultTypeMap.MatchNamesWithUnderscores`. - -Built-ins implementados: - -- `SnakeCase`; -- `Prefix`; -- `Suffix`; -- `Custom`; -- composicao via `Then`, `WithPrefix` e `WithSuffix`. - -Precedencia consolidada: - -```text -Mapping explicito do derivado - | - v -Mapping explicito herdado mais proximo - | - v -Mapping explicito herdado mais distante - | - v -Convention / Naming Policy do tipo consultado - | - v -Dapper Default -``` - -Consequencia: - -- explicit mapping sempre vence naming policy; -- inherited explicit mapping vence naming policy; -- naming policy e convention ficam no mesmo nivel e seguem a ordem de registro entre conventions; -- fallback do Dapper permanece disponivel quando nada no FluentMap resolve a coluna. - -## Delivery - -Arquivos adicionados: - -- `src/Dapper.FluentMap/Naming/NamingPolicy.cs` -- `src/Dapper.FluentMap/Conventions/NamingPolicyConvention.cs` -- `test/Dapper.FluentMap.Tests/NamingPolicyTests.cs` -- `docs/sdd/etapa-2/04-naming-policies.md` - -Arquivos alterados: - -- `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs` -- `docs/sdd/etapa-2/decisions.md` -- `docs/sdd/etapa-2/status.md` -- `README.md` - -Implementacao: - -- `NamingPolicy` encapsula uma funcao de transformacao imutavel; -- `SnakeCase` transforma PascalCase/camelCase em snake_case com tratamento basico de siglas; -- `Prefix` e `Suffix` adicionam texto antes/depois do nome gerado; -- `Custom` aceita `Func`; -- `Then`, `WithPrefix` e `WithSuffix` permitem compor policies; -- `UseNamingPolicy(NamingPolicy, bool caseSensitive = true)` registra a policy; -- `UseNamingPolicy(Func, bool caseSensitive = true)` e atalho para custom; -- a convention interna produz `PropertyMap` e passa pelas validacoes existentes. - -Cache e performance: - -- a transformacao ocorre no momento de `ForEntity(...)`, junto com o mapeamento de convention existente; -- os `PropertyMap` resultantes armazenam o nome de coluna ja transformado; -- a resolucao em runtime continua usando o cache estruturado do `MappingRegistry`; -- a chave de cache nao mudou: tipo, nome de coluna ordinal e estrategia (`FluentMap` ou `ConventionOnly`); -- mudancas feitas por `UseNamingPolicy(...).ForEntity(...)` invalidam o cache do tipo pelo mesmo caminho de `AddConvention`. - -## Tests - -Testes adicionados cobrem: - -- sem policy, preservando fallback do Dapper; -- snake_case; -- prefix composavel; -- suffix composavel; -- transformer customizado; -- explicit mapping maior que policy; -- inherited mapping maior que policy; -- policy junto com convention; -- case sensitivity; -- mesma policy aplicada em tipos diferentes; -- policy invalida retornando coluna nula; -- materializacao real com Dapper e SQLite in-memory; -- confirmacao de que `UseNamingPolicy` nao altera `DefaultTypeMap.MatchNamesWithUnderscores`; -- caracterizacao do comportamento nativo de `DefaultTypeMap.MatchNamesWithUnderscores`. - -## Validation - -Comandos executados durante a entrega: - -- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~NamingPolicyTests"` - - resultado: sucesso, 14 testes aprovados. -- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~MappingCompositionTests|FullyQualifiedName~InheritedMappingTests|FullyQualifiedName~DapperIntegrationTests|FullyQualifiedName~NamingPolicyTests"` - - resultado: sucesso, 42 testes aprovados. - -Validacao final completa registrada apos execucao: - -- `dotnet restore` - - resultado: sucesso. -- `dotnet build` - - resultado: sucesso, 0 warnings, 0 erros. -- `dotnet test` - - resultado: sucesso, 91 testes aprovados no core e 7 testes aprovados no Dommel. -- `dotnet build --configuration Release` - - resultado: sucesso, 0 warnings, 0 erros. -- `dotnet test --configuration Release` - - resultado: sucesso, 91 testes aprovados no core e 7 testes aprovados no Dommel. - -## Encerramento Da Etapa 2 - -Capacidades estabilizadas: - -- identidade interna de membros com `MemberPath`; -- validacao fail-fast e diagnosticos estruturados; -- heranca opt-in de mappings por `IncludeBase()`; -- naming policies declarativas e composaveis; -- precedencia consolidada entre mapping explicito, mapping herdado, convention/naming policy e fallback do Dapper. - -Dividas transferidas: - -- nested object materialization; -- Value Objects complexos; -- constructor/record mapping; -- multiple mapping profiles; -- Roslyn analyzers; -- source generators; -- AOT/trimming. - -Pack nao e esperado porque nao houve mudanca de empacotamento, metadados NuGet ou targets. diff --git a/docs/sdd/etapa-2/README.md b/docs/sdd/etapa-2/README.md deleted file mode 100644 index 137d280..0000000 --- a/docs/sdd/etapa-2/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# Etapa 2 - -## Objetivo - -Fortalecer a identidade interna de membros e propriedades para preparar evolucoes seguras em validacao, heranca de mappings e naming policies. - -## Dependencia Conceitual Da Etapa 1 - -A Etapa 2 depende das decisoes da Etapa 1 sobre resolucao de expressions, composicao entre mappings explicitos e conventions, `MappingRegistry`, caches estruturados, estado global e testes de integracao. - -Antes de alterar uma decisao registrada na Etapa 1, deve existir evidencia tecnica e uma nova decisao deve ser registrada nesta pasta. - -## Escopo - -Entregas: - -1. 01 - MemberPath -2. 02 - Validacao e diagnosticos -3. 03 - Heranca de mappings -4. 04 - Naming policies - -O escopo padrao continua sendo o projeto principal `Dapper.FluentMap`. `Dapper.FluentMap.Dommel` nao deve receber alteracao funcional nesta etapa, salvo se uma mudanca comprovada no core exigir adaptacao explicita. - -## Leitura Obrigatoria - -Antes das proximas entregas, leia: - -- `docs/sdd/etapa-1/README.md` -- `docs/sdd/etapa-1/status.md` -- `docs/sdd/etapa-1/decisions.md` -- `docs/sdd/etapa-1/04-mapping-registry-cache.md` -- `docs/sdd/etapa-2/README.md` -- `docs/sdd/etapa-2/status.md` -- `docs/sdd/etapa-2/decisions.md` -- o relatorio da entrega anterior nesta pasta - -## Compatibilidade Publica - -A API publica existente deve ser preservada sempre que possivel. `PropertyInfo` exposto por `IPropertyMap.PropertyInfo` e `PropertyMap.PropertyInfo` permanece como membro terminal por compatibilidade. - -## Fora Do Escopo - -`MemberPath` representa identidade e diagnostico de caminho. Ele nao implementa materializacao de objetos aninhados, Value Objects, custom materializers, source generators, wrappers de query ou geracao de SQL. diff --git a/docs/sdd/etapa-2/decisions.md b/docs/sdd/etapa-2/decisions.md deleted file mode 100644 index 52ef523..0000000 --- a/docs/sdd/etapa-2/decisions.md +++ /dev/null @@ -1,46 +0,0 @@ -# Decisoes Da Etapa 2 - -Registre aqui apenas decisoes que afetem entregas posteriores. - -## MemberPath - -- A identidade interna de uma propriedade mapeada deve usar o caminho completo de propriedades, nao apenas o `PropertyInfo.Name` terminal. -- `IPropertyMap.PropertyInfo` e `PropertyMap.PropertyInfo` permanecem publicos e continuam representando o membro terminal por compatibilidade. -- `MemberPath` nao implica materializacao de objetos aninhados; futuras entregas devem tratar validacao, diagnostico e naming policies sem declarar suporte a nested materialization. -- Comparacoes entre mapping explicito e convention devem usar a identidade de caminho quando disponivel, com fallback para caminho simples baseado no `PropertyInfo` terminal para implementacoes externas de `IPropertyMap`. -- Validacoes e diagnosticos futuros devem preferir `MemberPath.ToString()` para mensagens de caminho, preservando mensagens deterministicas sem depender apenas do nome terminal. -- Heranca de mappings deve comparar membros por caminho e identidade de membro, nao por string terminal. -- Naming policies futuras podem avaliar caminho completo, mas nao devem transformar isso em suporte implicito a materializacao aninhada. - -## Validacao E Diagnosticos - -- Erros inequivocos de configuracao devem falhar cedo durante construcao do map ou registro em `FluentMapper.Initialize`, sem depender de query ou materializacao pelo Dapper. -- `FluentMapConfigurationException`, derivada de `InvalidOperationException`, e a excecao publica para erros de configuracao estruturados do FluentMap. -- Nao foi adicionada API publica `Validate()` nesta entrega; futuras entregas so devem cria-la se houver diagnosticos agregaveis ou warnings com contrato claro. -- Mensagens de configuracao devem incluir entidade e, quando aplicavel, `MemberPath`, coluna, tipo do map/convention e causa. -- Conflitos de coluna dentro do mesmo entity map do core ou da mesma convention sao invalidos quando mais de uma propriedade pode responder pela mesma coluna, incluindo sobreposicao por case-insensitive. -- Implementacoes externas de `IPropertyMap` nao recebem validacao global de conflito de coluna, porque integracoes como Dommel podem reutilizar colunas com semantica adicional propria. -- Conflitos entre mapping explicito e convention para a mesma coluna continuam fora do escopo de erro imediato e seguem a precedencia explicito -> convention -> Dapper default. - -## Heranca De Mappings - -- Heranca de mappings e opt-in por `IncludeBase()`; nao ha aplicacao automatica de maps de classes base por reflection. -- O map base deve estar registrado antes do map derivado; a ausencia do base map falha cedo com `FluentMapConfigurationException`. -- A composicao de mappings explicitos para um tipo derivado segue a ordem: mappings proprios do derivado, mappings herdados mais proximos, mappings herdados mais distantes. -- A precedencia final passa a ser: mapping explicito do derivado -> mapping explicito herdado -> convention do tipo consultado -> Dapper default. -- Overrides sao definidos por `MemberPath`: quando derivado e base mapeiam o mesmo path, o derivado vence e o mapping base sobrescrito nao participa da resolucao para o derivado. -- Conflitos de coluna entre mappings explicitos do derivado e mappings herdados de paths diferentes sao invalidos e diagnosticados durante o registro do map derivado. -- `IncludeBase()` aceita apenas classe base real do tipo mapeado; tipos nao relacionados, o proprio tipo e interfaces ficam fora do contrato desta entrega. -- Naming policies futuras devem respeitar a composicao explicita efetiva antes de aplicar conventions ou fallback. - -## Naming Policies - -- Naming policy e integrada ao mecanismo existente de conventions; nao ha segundo pipeline de resolucao. -- A API publica usa `NamingPolicy`, uma abstracao leve baseada em delegate, em vez de uma interface publica prematura. -- `FluentMapConfiguration.UseNamingPolicy(...)` retorna `FluentConventionConfiguration`, preservando `.ForEntity()`, `.ForEntitiesInAssembly(...)` e `.ForEntitiesInCurrentAssembly(...)`. -- Built-ins adicionados: `SnakeCase`, `Prefix`, `Suffix` e `Custom`, com composicao por `Then`, `WithPrefix` e `WithSuffix`. -- `SnakeCase` nao altera `DefaultTypeMap.MatchNamesWithUnderscores`; a policy gera `PropertyMap` dentro do FluentMap e evita efeito global silencioso no Dapper. -- A precedencia consolidada e: mapping explicito do derivado -> mapping explicito herdado mais proximo -> mapping explicito herdado mais distante -> convention/naming policy do tipo consultado -> Dapper default. -- Naming policy e convention compartilham o mesmo nivel de precedencia e seguem a ordem de registro entre conventions. -- Transformacao baseada em `MemberPath` completo continua fora do contrato publico, pois a etapa nao implementa materializacao aninhada. -- Invalid policy que produz coluna nula ou vazia falha cedo com `FluentMapConfigurationException` durante a configuracao da entidade. diff --git a/docs/sdd/etapa-2/status.md b/docs/sdd/etapa-2/status.md deleted file mode 100644 index 7b79d5a..0000000 --- a/docs/sdd/etapa-2/status.md +++ /dev/null @@ -1,8 +0,0 @@ -# Status Da Etapa 2 - -| Entrega | Status | Commit | -|---|---|---| -| 01 - MemberPath | Concluido | 9a91299 | -| 02 - Validacao e diagnosticos | Concluido | 8611362 | -| 03 - Heranca de mappings | Concluido | 5735b69 | -| 04 - Naming policies | Concluido | feat: add configurable naming policies | diff --git a/docs/sdd/etapa-3/01-mapping-registration.md b/docs/sdd/etapa-3/01-mapping-registration.md deleted file mode 100644 index efb5696..0000000 --- a/docs/sdd/etapa-3/01-mapping-registration.md +++ /dev/null @@ -1,290 +0,0 @@ -# 01 - Registro E Descoberta De Mappings - -## Specification - -Modernizar o registro de mappings sem abandonar a proposta central do FluentMap: mapping externo, fortemente tipado e sem atributos no modelo. - -Objetivos tratados: - -- preservar `AddMap(new CustomerMap())`; -- adicionar registro explicito sem scanning por tipo de map, como `AddMap()`; -- adicionar descoberta por assembly como conveniencia; -- adicionar marker type para escolher o assembly sem depender de `Assembly.GetCallingAssembly()`; -- integrar todos os caminhos ao `MappingRegistry`; -- preservar validacoes, inheritance mappings, conventions, naming policies e precedencia consolidada; -- tornar duplicidades deterministicas e diagnosticas; -- documentar reflection restante sem declarar suporte AOT/trimming completo. - -Fora do objetivo: - -- remover APIs antigas; -- criar DI container ou integrar `IServiceCollection`; -- transformar scanning em mecanismo principal de startup; -- implementar source generator, analyzer ou AOT completo; -- alterar funcionalmente Dommel. - -## Discovery - -Arquivos analisados: - -- `AGENTS.md` -- `.agents/skills/run-tests/SKILL.md` -- `docs/sdd/etapa-1/README.md` -- `docs/sdd/etapa-1/status.md` -- `docs/sdd/etapa-1/decisions.md` -- `docs/sdd/etapa-1/04-mapping-registry-cache.md` -- `docs/sdd/etapa-2/README.md` -- `docs/sdd/etapa-2/status.md` -- `docs/sdd/etapa-2/decisions.md` -- `docs/sdd/etapa-2/01-member-path.md` -- `docs/sdd/etapa-2/02-configuration-validation.md` -- `docs/sdd/etapa-2/03-inherited-mappings.md` -- `docs/sdd/etapa-2/04-naming-policies.md` -- `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs` -- `src/Dapper.FluentMap/Utils/FluentMapConfigurationExtensions.cs` -- `src/Dapper.FluentMap/MappingRegistry.cs` -- `src/Dapper.FluentMap/Mapping/EntityMap.cs` -- `src/Dapper.FluentMap/MappingConfigurationValidator.cs` -- `src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs` -- testes de manual mapping, conventions e registry. - -Formas atuais de registro: - -- `FluentMapper.Initialize(Action)` reutiliza uma instancia estatica de `FluentMapConfiguration`. -- `FluentMapConfiguration.AddMap(IEntityMap mapper)` registra uma instancia ja criada. -- `FluentMapConfiguration.AddConvention()` cria a convention por `new()` e retorna `FluentConventionConfiguration`. -- `FluentConventionConfiguration.ForEntity()` aplica convention para uma entidade explicita. -- `FluentConventionConfiguration.ForEntitiesInAssembly(...)` usa `Assembly.GetExportedTypes()` para entidades e registra conventions por tipo. -- `FluentMapConfigurationExtensions.ApplyMapsFromAssemblies(...)` e a API historica de discovery de entity maps por assembly. - -Como `EntityMap` e registrado hoje: - -- o consumidor instancia manualmente o map; -- `AddMap(IEntityMap)` valida nulo e chama `FluentMapper.Registry.AddEntityMap(mapper)`; -- `MappingRegistry.AddEntityMap` valida duplicidade de entidade, valida o map, valida bases incluidas, valida composicao efetiva, grava em `EntityMaps`, invalida cache do tipo e instala `FluentMapTypeMap` no Dapper. - -Mappings por tipo: - -- nao havia API direta `AddMap()`; -- o caminho por tipo existia apenas indiretamente em `ApplyMapsFromAssemblies(...)`, via reflection. - -Instanciacao: - -- registro explicito exigia instancia manual; -- conventions usam constraint `new()`; -- discovery historico usa `Activator.CreateInstance(type)`. - -Assembly scanning historico: - -- `ApplyMapsFromAssemblies(...)` chama `Assembly.GetTypes()`; -- ignora tipos abstratos e interfaces; -- encontra tipos com interface fechada `IEntityMap<>`; -- detecta mais de um map para a mesma entidade antes do registro; -- usa `GetMethod(nameof(AddMap))`, `MakeGenericMethod(...)`, `Invoke(...)` e `Activator.CreateInstance(...)`; -- a ordem de registro vem da ordem retornada por reflection. - -Constraints de construtor: - -- instancia manual nao exige construtor especifico da API; -- discovery historico exige construtor sem parametros em runtime; -- quando construtor falta ou lanca, a falha vem de reflection e pode chegar embrulhada por `TargetInvocationException`. - -Duplicidades: - -- duplicidade de entidade no registry falha com `FluentMapConfigurationException`; -- duplicidade dentro do mesmo assembly scan historico falha com `InvalidOperationException`; -- o modelo validado na Etapa 2 nao permite conflito silencioso de coluna em `PropertyMap` do core. - -Validacao: - -- entity maps sao validados no registro global, antes de gravar no registry; -- inheritance por `IncludeBase()` exige base map ja registrado; -- conventions e naming policies compartilham o pipeline de convention e validacao existente. - -Scanning de tipos invalidos: - -- abstratos e interfaces ja sao ignorados pelo discovery historico; -- genericos abertos nao sao tratados explicitamente no discovery historico; -- tipos concretos sem construtor publico sem parametros falham durante `Activator.CreateInstance`. - -## Decision - -APIs adicionadas: - -```csharp -configuration.AddMap(); - -configuration - .AddMap() - .AddMap(); - -configuration.AddMapsFromAssembly(typeof(CustomerMap).Assembly); - -configuration.AddMapsFromAssemblyContaining(); -``` - -Tambem serao aceitos filtros opcionais de namespace nos metodos de scanning, seguindo o estilo de `ForEntitiesInAssembly(...)`: - -```csharp -configuration.AddMapsFromAssembly(assembly, "App.Domain.Maps"); -configuration.AddMapsFromAssemblyContaining("App.Domain.Maps"); -``` - -Registro de instancia: - -- `AddMap(IEntityMap mapper)` permanece como API historica; -- assinatura e comportamento sao preservados. - -Registro generico: - -- `AddMap()` representa o caminho explicito sem assembly scanning; -- `TMap` deve implementar `IEntityMap` e possuir construtor publico sem parametros; -- o tipo deve implementar exatamente uma interface fechada `IEntityMap`; -- a entidade e inferida dessa interface e o registro passa pelo mesmo `MappingRegistry`. - -Assembly scanning: - -- scanning moderno usa apenas tipos exportados da assembly; -- tipos abstratos, interfaces e genericos abertos sao ignorados; -- candidatos sao ordenados deterministamente por nome completo antes de qualquer decisao; -- duplicidade de entidade dentro do conjunto descoberto falha antes da instanciacao; -- maps sao instanciados antes do registro para permitir ordenacao por `IncludeBase()`; -- quando um map inclui base map tambem descoberto no mesmo conjunto, o registro e ordenado para registrar a base primeiro; -- ciclos ou dependencias impossiveis falham com diagnostico. - -Duplicidades: - -- mesmo mapping registrado duas vezes: falha pelo registry porque a entidade ja possui map; -- mesma entidade com mappings diferentes: falha pelo registry ou pelo preflight do scanning; -- registro explicito seguido de scanning da mesma entidade: falha pelo registry durante o scanning; -- nao ha "ultimo ganha". - -Reflection: - -- `AddMap()` nao faz assembly scanning, mas usa reflection limitada para inferir `TEntity` de `IEntityMap`; -- `AddMapsFromAssembly(...)` depende de reflection e `Activator.CreateInstance`; -- `MappingRegistry` ainda usa `Activator.CreateInstance(typeof(FluentMapTypeMap<>).MakeGenericType(type))` para instalar type map no Dapper; -- AOT/trimming completo permanece divida futura. - -## Delivery - -Arquivos alterados: - -- `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs` -- `src/Dapper.FluentMap/MappingRegistry.cs` -- `src/Dapper.FluentMap/Utils/FluentMapConfigurationExtensions.cs` -- `test/Dapper.FluentMap.Tests/MappingRegistrationTests.cs` -- `README.md` -- `docs/sdd/etapa-3/README.md` -- `docs/sdd/etapa-3/status.md` -- `docs/sdd/etapa-3/decisions.md` -- `docs/sdd/etapa-3/01-mapping-registration.md` - -API anterior preservada: - -```csharp -configuration.AddMap(new CustomerMap()); -``` - -APIs novas: - -```csharp -configuration.AddMap(); - -configuration - .AddMap() - .AddMap(); - -configuration.AddMapsFromAssembly(typeof(CustomerMap).Assembly); -configuration.AddMapsFromAssembly(typeof(CustomerMap).Assembly, "App.Domain.Maps"); - -configuration.AddMapsFromAssemblyContaining(); -configuration.AddMapsFromAssemblyContaining("App.Domain.Maps"); -``` - -Implementacao: - -- `FluentMapConfiguration.AddMap()` cria o map e retorna a propria configuracao para permitir chaining; -- o tipo de entidade e inferido pela interface fechada `IEntityMap`; -- `MappingRegistry` recebeu overload interno `AddEntityMap(Type, IEntityMap)` para registrar o tipo inferido sem `MakeGenericMethod`/`Invoke`; -- o overload generico antigo do registry foi preservado e delega para o novo overload interno; -- `AddMapsFromAssembly(...)` usa tipos exportados da assembly, filtra namespace opcional, ignora abstratos/interfaces/genericos abertos, detecta duplicidades antes de instanciar e registra de forma deterministica; -- `AddMapsFromAssemblyContaining(...)` usa a assembly do marker type e compartilha o mesmo fluxo; -- scanning instancia todos os maps descobertos antes do registro e ordena por `IncludeBase()` quando base e derivado aparecem no mesmo conjunto descoberto; -- `ApplyMapsFromAssemblies(...)` foi mantido por compatibilidade e recebeu apenas ajuste de comentario XML para evitar ambiguidade com a nova overload. - -Duplicidades: - -- mesmo mapping registrado duas vezes: `FluentMapConfigurationException` pelo registry; -- mesma entidade com mappings diferentes por registro explicito: `FluentMapConfigurationException` pelo registry; -- mesma entidade duplicada dentro do scanning: `FluentMapConfigurationException` antes de qualquer registro; -- registro explicito seguido de scanning da mesma entidade: `FluentMapConfigurationException` durante o registro descoberto; -- nenhum fluxo novo usa "ultimo ganha". - -Reflection restante: - -- `AddMap()` nao faz assembly scanning, mas usa reflection para identificar a unica interface `IEntityMap`; -- `AddMapsFromAssembly(...)` usa `Assembly.GetExportedTypes()` e `Activator.CreateInstance`; -- `MappingRegistry` continua usando `Activator.CreateInstance(typeof(FluentMapTypeMap<>).MakeGenericType(type))` para instalar type maps no Dapper; -- essas dependencias ficam registradas como divida futura para AOT/trimming. - -Compatibilidade: - -- nenhuma API publica foi removida ou marcada como obsoleta; -- `AddMap(new CustomerMap())` continua funcionando; -- target `netstandard2.0` do core foi preservado; -- Dommel nao recebeu alteracao funcional; -- conventions, naming policies, inheritance mappings e validacao continuam passando pelo `MappingRegistry`. - -Testes adicionados em `MappingRegistrationTests` cobrem: - -- registro por instancia existente; -- registro generico; -- materializacao real com Dapper via registro generico; -- chaining de multiplos mappings explicitos; -- scanning por assembly; -- marker type; -- map abstrato ignorado; -- tipo invalido que nao implementa `IEntityMap`; -- mesmo mapping registrado duas vezes; -- entidade duplicada com maps diferentes; -- duplicidade detectada dentro do scanning antes de registro parcial; -- scanning apos registro explicito; -- erro de construtor; -- validacao integrada; -- ordenacao de scanning para inheritance mappings. - -## Validation - -Ambiente: - -- SDK: `10.0.302` -- test runner detectado: VSTest com xUnit v3 -- projeto principal: `netstandard2.0` -- projeto de testes do core: `net10.0` - -Comandos de validacao localizada: - -- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~MappingRegistrationTests"` - - resultado: sucesso, 15 testes aprovados. -- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~MappingRegistrationTests|FullyQualifiedName~MappingRegistryTests|FullyQualifiedName~MappingCompositionTests|FullyQualifiedName~InheritedMappingTests|FullyQualifiedName~ConventionTests|FullyQualifiedName~NamingPolicyTests|FullyQualifiedName~DapperIntegrationTests"` - - resultado: sucesso, 69 testes aprovados. - -Validacao final: - -- `dotnet restore` - - resultado: sucesso. -- `dotnet build` - - resultado: sucesso, 0 warnings, 0 erros. -- `dotnet test` - - resultado: sucesso, 106 testes aprovados no core e 7 testes aprovados no Dommel. -- `dotnet build --configuration Release` - - resultado: sucesso, 0 warnings, 0 erros. -- `dotnet test --configuration Release` - - resultado: sucesso, 106 testes aprovados no core e 7 testes aprovados no Dommel. -- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release` - - resultado: sucesso, 106 testes aprovados. -- `dotnet build .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release --no-restore` - - resultado: sucesso, 0 warnings, 0 erros. - -`dotnet pack` nao foi executado porque nao houve mudanca de empacotamento, metadados NuGet ou targets. diff --git a/docs/sdd/etapa-3/02-constructor-immutable-mapping.md b/docs/sdd/etapa-3/02-constructor-immutable-mapping.md deleted file mode 100644 index 0da91e7..0000000 --- a/docs/sdd/etapa-3/02-constructor-immutable-mapping.md +++ /dev/null @@ -1,207 +0,0 @@ -# 02 - Constructor Mapping E Imutaveis - -## Specification - -Esta entrega melhora a integracao do FluentMap com constructor mapping do Dapper para modelos com construtores parametrizados, propriedades somente leitura, propriedades `init`, records e classes imutaveis. - -Objetivos tratados: - -- permitir que `Map(e => e.Name).ToColumn("full_name")` influencie parametros de construtor correspondentes; -- aplicar mappings explicitos, mappings herdados, conventions e naming policies tambem na selecao de construtor; -- preservar fallback do `DefaultTypeMap` quando nao houver configuracao relevante do FluentMap; -- manter a precedencia consolidada: mapping explicito do derivado -> mapping explicito herdado -> convention/naming policy -> Dapper default; -- validar materializacao real por `Dapper.QuerySingle` com SQLite in-memory. - -Fora do objetivo: - -- criar materializador concorrente ao Dapper; -- gerar IL proprio; -- criar object factory; -- adicionar DSL publica `MapConstructor(...)`; -- implementar nested object materialization ou Value Objects. - -## Discovery - -Arquivos analisados: - -- `AGENTS.md` -- `.agents/skills/run-tests/SKILL.md` -- `docs/sdd/etapa-1/README.md` -- `docs/sdd/etapa-1/decisions.md` -- `docs/sdd/etapa-2/README.md` -- `docs/sdd/etapa-2/decisions.md` -- `docs/sdd/etapa-3/README.md` -- `docs/sdd/etapa-3/status.md` -- `docs/sdd/etapa-3/decisions.md` -- `docs/sdd/etapa-3/01-mapping-registration.md` -- `src/Dapper.FluentMap/TypeMaps/MultiTypeMap.cs` -- `src/Dapper.FluentMap/TypeMaps/FluentTypeMap.cs` -- `src/Dapper.FluentMap/TypeMaps/FluentConventionTypeMap.cs` -- `src/Dapper.FluentMap/MappingRegistry.cs` -- `src/Dapper.FluentMap/Mapping/MemberPath.cs` -- testes de integracao, composition, inheritance e naming policies. - -Contratos do Dapper 2.1.79 analisados: - -- `SqlMapper.ITypeMap.FindConstructor(string[] names, Type[] types)`; -- `SqlMapper.ITypeMap.GetConstructorParameter(ConstructorInfo constructor, string columnName)`; -- `SqlMapper.IMemberMap.Parameter`; -- `DefaultTypeMap`; -- `CustomPropertyTypeMap`. - -Comportamento encontrado: - -- `FluentMapTypeMap` e `FluentConventionTypeMap` eram compostos por `CustomPropertyTypeMap` e `DefaultTypeMap`. -- `CustomPropertyTypeMap` resolve propriedades, mas nao fornece constructor mapping. -- `MultiTypeMap.FindConstructor` acabava delegando ao `DefaultTypeMap`. -- `MultiTypeMap.GetConstructorParameter` tambem dependia do `DefaultTypeMap`, mas o `CustomPropertyTypeMap` do Dapper 2.1.79 pode lancar `NotSupportedException` nesse metodo. -- `DefaultTypeMap.FindConstructor` seleciona construtor por nomes e tipos de colunas na ordem recebida do reader; construtor sem parametros vence cedo; construtores parametrizados precisam ter a mesma quantidade de parametros que as colunas consideradas. -- `DefaultTypeMap.GetConstructorParameter` associa coluna a parametro por nome do parametro, com matching case-insensitive e suporte ao flag global `MatchNamesWithUnderscores`. -- Mappings explicitos, herdados, conventions e naming policies do FluentMap ja influenciavam `GetMember`, mas nao os nomes usados por `FindConstructor`. -- Records posicionais e classes imutaveis falhavam quando a coluna configurada nao tinha o mesmo nome do parametro do construtor. -- Com SQLite, colunas inteiras sao expostas como `Int64`; para colunas mapeadas pelo FluentMap, a entrega usa o tipo da propriedade mapeada na chamada ao `DefaultTypeMap.FindConstructor`, preservando a conversao final do Dapper. - -Caracterizacao antes da alteracao: - -- `TraditionalPocoShouldContinueMaterializingConfiguredColumn` passava. -- `ParameterlessConstructorShouldContinueUsingSettableProperties` passava. -- `NestedMemberPathMappingShouldNotActAsConstructorParameterMapping` passava como falha esperada de materializacao. -- Falhavam records, classes imutaveis, explicit mappings para parametros, naming policy, convention, multiplos construtores, casing diferente, fallback parcial e inheritance, sempre porque o Dapper via nomes crus como `person_id` e `full_name`. - -## Decision - -A lacuna pertence ao FluentMap apenas na traducao de metadata: - -- coluna recebida do reader; -- propriedade simples configurada pelo FluentMap; -- nome e tipo que o `DefaultTypeMap` deve enxergar para escolher o construtor; -- `ParameterInfo` que o Dapper deve receber por `IMemberMap.Parameter`. - -A materializacao continua pertencendo ao Dapper. - -Estrategia: - -- adicionar um type map interno `FluentConstructorTypeMap`; -- inserir esse mapper antes de `CustomPropertyTypeMap` e antes de `DefaultTypeMap`; -- quando uma coluna resolve para um `IPropertyMap` simples e nao ignorado, chamar `DefaultTypeMap.FindConstructor` com nome e tipo da propriedade; -- quando uma coluna nao possui mapping simples, manter nome e tipo originais e deixar o `DefaultTypeMap` atuar como fallback; -- implementar um `IMemberMap` interno apenas para expor `ParameterInfo`; -- preservar `GetMember` existente para propriedades settable. - -Precedencia: - -1. mapping explicito do derivado; -2. mapping explicito herdado mais proximo; -3. demais mappings herdados; -4. convention/naming policy; -5. fallback do `DefaultTypeMap`. - -Inheritance: - -- constructor mapping usa a composicao efetiva ja existente no `MappingRegistry`; -- `IncludeBase()` continua opt-in; -- mappings herdados podem traduzir colunas para parametros do construtor do tipo derivado quando o parametro corresponde a propriedade simples herdada. - -Conflitos e ambiguidades: - -- conflitos de coluna dentro de entity map e convention continuam falhando cedo pelas validacoes da Etapa 2; -- ambiguidades de constructor overload continuam sob responsabilidade do algoritmo do Dapper; -- nenhum erro novo de ambiguidade de construtor foi criado nesta entrega. - -MemberPath: - -- constructor parameter nao e representado como `MemberPath`; -- somente mappings cujo `MemberPath` nao e aninhado participam do constructor mapping; -- mapping como `Map(e => e.Rank.Level).ToColumn("rank_level")` nao e usado para preencher parametro `level` do construtor raiz; -- nested object materialization e Value Objects permanecem fora do contrato. - -Records e `init`: - -- records posicionais funcionam porque seus parametros correspondem a propriedades simples; -- propriedades `init` continuam sendo tratadas pelo Dapper conforme seu proprio suporte a setter/constructor; -- esta entrega nao adiciona API nem regra especial para `init`. - -Parametros opcionais: - -- nao foi criada regra especial para parametros opcionais; -- a selecao segue `DefaultTypeMap`: construtor parametrizado precisa corresponder a assinatura esperada pelo Dapper. - -## Delivery - -Arquivos alterados: - -- `src/Dapper.FluentMap/MappingRegistry.cs` -- `src/Dapper.FluentMap/TypeMaps/ConstructorParameterMap.cs` -- `src/Dapper.FluentMap/TypeMaps/FluentConstructorTypeMap.cs` -- `src/Dapper.FluentMap/TypeMaps/FluentConventionTypeMap.cs` -- `src/Dapper.FluentMap/TypeMaps/FluentTypeMap.cs` -- `src/Dapper.FluentMap/TypeMaps/MultiTypeMap.cs` -- `test/Dapper.FluentMap.Tests/ConstructorMappingTests.cs` -- `docs/sdd/etapa-3/02-constructor-immutable-mapping.md` -- `docs/sdd/etapa-3/decisions.md` -- `docs/sdd/etapa-3/status.md` - -Implementacao: - -- `MappingRegistry` passou a expor resolucao interna de `IPropertyMap`, reaproveitando o cache estruturado existente. -- `FluentConstructorTypeMap` traduz colunas mapeadas para nomes/tipos de propriedades simples e delega a selecao ao `DefaultTypeMap`. -- `ConstructorParameterMap` implementa `SqlMapper.IMemberMap` para fornecer `ParameterInfo` ao Dapper. -- `MultiTypeMap.GetConstructorParameter` passou a ignorar `NotSupportedException` de mappers que nao suportam constructor parameter mapping, permitindo fallback real. -- `FluentMapTypeMap` e `FluentConventionTypeMap` passaram a compor o mapper de construtor antes do mapper de propriedades. - -Testes adicionados cobrem: - -- POCO tradicional; -- record posicional; -- classe imutavel; -- mapping explicito para parametro; -- naming policy para parametro; -- convention para parametro; -- construtor unico; -- multiplos construtores; -- construtor sem parametros; -- casing diferente; -- parameter mapping com fallback Dapper; -- mapping herdado; -- nested `MemberPath` nao usado como parametro de construtor; -- materializacao real via SQLite in-memory. - -## Validation - -Ambiente: - -- SDK: `10.0.302` -- test runner detectado: VSTest com xUnit v3 -- projeto principal: `netstandard2.0` -- projeto de testes do core: `net10.0` - -Validacao localizada: - -- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~ConstructorMappingTests"` - - antes da implementacao: 8 falhas e 3 sucessos, reproduzindo a lacuna. - - depois da implementacao: sucesso, 11 testes aprovados. - -Validacao final: - -- `dotnet restore` - - resultado: sucesso. -- `dotnet build` - - resultado: sucesso, 0 warnings, 0 erros. -- `dotnet test` - - resultado: sucesso, 117 testes aprovados no core e 7 testes aprovados no Dommel. -- `dotnet build --configuration Release` - - resultado: sucesso, 0 warnings, 0 erros. -- `dotnet test --configuration Release` - - resultado: sucesso, 117 testes aprovados no core e 7 testes aprovados no Dommel. -- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --filter "Category=Integration"` - - resultado: sucesso, 21 testes de integracao aprovados. - -`dotnet pack` nao foi executado porque nao houve mudanca de empacotamento, metadados NuGet ou targets. - -## Limitacoes - -- Constructor overload ambiguity continua seguindo o Dapper. -- Parametros opcionais nao recebem tratamento especial. -- Nested object materialization e Value Objects continuam fora do contrato. -- O suporte AOT/trimming nao foi ampliado. -- `DefaultTypeMap.MatchNamesWithUnderscores` continua sendo flag global do Dapper e nao e alterado por naming policies do FluentMap. diff --git a/docs/sdd/etapa-3/03-diagnostics-api.md b/docs/sdd/etapa-3/03-diagnostics-api.md deleted file mode 100644 index 26a79ff..0000000 --- a/docs/sdd/etapa-3/03-diagnostics-api.md +++ /dev/null @@ -1,324 +0,0 @@ -# 03 - Validate E Explain - -## Specification - -Consolidar diagnosticos de configuracao para o `Dapper.FluentMap` apos a introducao de registro moderno, mappings herdados, naming policies e constructor mapping. - -Objetivos tratados: - -- expor validacao publica para o estado global atual; -- agregar erros quando o estado configurado contem mais de uma falha; -- explicar mappings efetivos por entidade sem retornar apenas texto; -- representar origem do mapping de forma estruturada; -- incluir explicit mapping, inherited mapping, convention, naming policy, fallback do Dapper e constructor parameter; -- preservar caches, registry e type maps do Dapper sem side effects de diagnostico. - -Fora do objetivo: - -- logging; -- acesso a banco; -- I/O; -- alteracao de comportamento de materializacao; -- sistema de profiles; -- diagnostico query-specific. - -## Discovery - -Arquivos analisados: - -- `AGENTS.md` -- `.agents/skills/run-tests/SKILL.md` -- `docs/sdd/etapa-1/README.md` -- `docs/sdd/etapa-1/decisions.md` -- `docs/sdd/etapa-2/README.md` -- `docs/sdd/etapa-2/decisions.md` -- `docs/sdd/etapa-2/02-configuration-validation.md` -- `docs/sdd/etapa-3/README.md` -- `docs/sdd/etapa-3/status.md` -- `docs/sdd/etapa-3/decisions.md` -- `docs/sdd/etapa-3/01-mapping-registration.md` -- `docs/sdd/etapa-3/02-constructor-immutable-mapping.md` -- `src/Dapper.FluentMap/FluentMapper.cs` -- `src/Dapper.FluentMap/MappingRegistry.cs` -- `src/Dapper.FluentMap/MappingConfigurationValidator.cs` -- `src/Dapper.FluentMap/Mapping/EntityMap.cs` -- `src/Dapper.FluentMap/Mapping/PropertyMap.cs` -- `src/Dapper.FluentMap/Mapping/MemberPath.cs` -- `src/Dapper.FluentMap/Mapping/PropertyMapIdentity.cs` -- `src/Dapper.FluentMap/Conventions/*` -- `src/Dapper.FluentMap/TypeMaps/*` -- testes existentes de validacao, composicao, inheritance, naming policies e constructor mapping. - -Estado anterior da validacao: - -- a Etapa 2 criou `FluentMapConfigurationException` e `MappingConfigurationValidator`; -- a validacao era fail-fast durante construcao ou registro; -- a decisao documentada na Etapa 2 foi nao expor `Validate()`, porque ainda nao havia modelo agregado ou API de diagnostico; -- nenhuma API `Explain()` existia. - -Pipeline mapeado: - -```text -FluentMapper.Initialize -↓ -FluentMapConfiguration -↓ -MappingRegistry -↓ -MappingConfigurationValidator -↓ -entity maps + included base maps + conventions/naming policies -↓ -FluentMapTypeMap -↓ -FluentConstructorTypeMap + CustomPropertyTypeMap + DefaultTypeMap -↓ -constructor parameter/property member/fallback -``` - -Informacoes disponiveis: - -- `EntityMaps` guarda entity maps registrados por entidade; -- `IEntityMapWithIncludedBaseTypes` preserva bases incluidas; -- `PropertyMapIdentity` preserva `MemberPath` completo; -- conventions e naming policies produzem `PropertyMap` no registro; -- `NamingPolicyConvention` permite distinguir naming policy de convention comum; -- constructor mapping ja possui algoritmo interno para associar propriedade simples a parametro; -- fallback do Dapper pode ser explicado por propriedades publicas simples que nao possuem mapping efetivo do FluentMap. - -Informacao descartada: - -- a resolucao hot path retornava apenas `IPropertyMap`/`PropertyInfo`, sem provenance; -- a provenance pode ser reconstruida sem duplicar estado usando a composicao existente do registry. - -## Decision - -### Validate - -`FluentMapper.Validate()` foi exposto como API publica. - -Contrato: - -- valida o estado global atual de `FluentMapper`; -- retorna `void`; -- lanca `FluentMapConfigurationException` quando encontra erros; -- agrega mensagens de mais de uma falha quando o estado atual contem multiplos problemas; -- e idempotente; -- nao altera registry, caches, conventions, entity maps ou type maps do Dapper; -- nao faz I/O, logging ou acesso a banco. - -Motivo para mudar a decisao da Etapa 2: - -- apos as entregas de registro moderno, inheritance, naming policies e constructor mapping, ha mais fontes de configuracao coexistindo; -- o diagnostico agregado agora tem utilidade observavel para tooling, testes de startup e auditoria de configuracao; -- a API permanece pequena e reaproveita as regras ja existentes de validacao. - -### Explain - -`FluentMapper.Explain()` foi exposto como API publica. - -Contrato: - -- retorna `MappingExplanation`; -- funciona antes ou depois de `Initialize`; -- para entidade sem FluentMap registrado, retorna fallback do Dapper para propriedades publicas simples e diagnostico textual auxiliar; -- nao cria mappings; -- nao instala type maps; -- nao invalida caches; -- nao consulta banco; -- produz snapshots read-only. - -Modelo publico: - -```csharp -MappingExplanation -{ - EntityType, - EntityMapType, - ConventionTypes, - Members, - Diagnostics -} - -MemberMappingExplanation -{ - MemberPath, - PropertyInfo, - ColumnName, - Source, - CaseSensitive, - Ignored, - InheritedFrom, - ConventionType, - ConstructorParameters -} - -ConstructorParameterExplanation -{ - Constructor, - Name, - ParameterType -} -``` - -Provenance publica: - -```text -Explicit -Inherited -Convention -NamingPolicy -DapperDefault -``` - -Constructor parameters nao foram modelados como source. Eles sao destino adicional associado a um mapping simples, preservando a distincao entre origem do mapping e destino de materializacao. - -## Delivery - -Arquivos adicionados: - -- `src/Dapper.FluentMap/Diagnostics/MappingSource.cs` -- `src/Dapper.FluentMap/Diagnostics/MappingExplanation.cs` -- `src/Dapper.FluentMap/Diagnostics/MemberMappingExplanation.cs` -- `src/Dapper.FluentMap/Diagnostics/ConstructorParameterExplanation.cs` -- `test/Dapper.FluentMap.Tests/DiagnosticsApiTests.cs` -- `docs/sdd/etapa-3/03-diagnostics-api.md` - -Arquivos alterados: - -- `src/Dapper.FluentMap/FluentMapper.cs` -- `src/Dapper.FluentMap/MappingRegistry.cs` -- `src/Dapper.FluentMap/TypeMaps/FluentConstructorTypeMap.cs` -- `docs/sdd/etapa-3/README.md` -- `docs/sdd/etapa-3/status.md` -- `docs/sdd/etapa-3/decisions.md` - -APIs publicas adicionadas: - -```csharp -FluentMapper.Validate(); -FluentMapper.Explain(); -``` - -Tipos publicos adicionados: - -```text -Dapper.FluentMap.Diagnostics.MappingSource -Dapper.FluentMap.Diagnostics.MappingExplanation -Dapper.FluentMap.Diagnostics.MemberMappingExplanation -Dapper.FluentMap.Diagnostics.ConstructorParameterExplanation -``` - -Exemplo conceitual: - -```text -Id - Column: customer_id - Source: Explicit - -Name - Column: customer_name - Source: NamingPolicy - Constructor parameter: name - -CreatedAt - Column: CreatedAt - Source: DapperDefault -``` - -Implementacao: - -- `Validate()` chama o registry e reexecuta as validacoes existentes sobre maps, bases incluidas, composicao efetiva e conventions; -- erros encontrados em estado global ja corrompido por mutabilidade legada dos dicionarios sao agregados; -- `Explain()` deriva provenance a partir de entity maps, included base maps, conventions e naming policies ja registrados; -- mappings herdados preservam o tipo base que declarou o mapping; -- naming policies sao distinguidas pela convention interna `NamingPolicyConvention`; -- fallback do Dapper e representado por propriedades publicas simples sem mapping efetivo no snapshot; -- constructor parameters sao detectados apenas para mappings simples e nao ignorados; -- nested `MemberPath` aparece no diagnostico, mas nao e tratado como constructor parameter. - -## Compatibility - -Compatibilidade preservada: - -- nenhuma API publica existente foi removida; -- `FluentMapper.Initialize` manteve comportamento; -- `EntityMaps` e `TypeConventions` permanecem publicos por compatibilidade; -- validacoes fail-fast existentes continuam ocorrendo no registro; -- constructor mapping da Entrega 02 nao mudou o contrato de materializacao; -- Dommel nao recebeu alteracao funcional. - -Comportamento publico novo: - -- consumidores podem chamar `FluentMapper.Validate()` para validar o estado global atual; -- consumidores podem chamar `FluentMapper.Explain()` para obter diagnostico estruturado. - -## Tests - -Testes adicionados cobrem: - -- `Validate()` com configuracao valida; -- configuracao invalida; -- multiplos erros agregados; -- chamada repetida; -- ausencia de side effects em cache/registry; -- explicit mapping; -- inherited mapping; -- convention; -- naming policy; -- Dapper default fallback; -- constructor parameter; -- entidade sem mapping; -- paths distintos com mesmo terminal; -- metadata read-only; -- chamada repetida de `Explain()` consistente. - -## Validation - -Validacao localizada executada: - -- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~DiagnosticsApiTests"` - - resultado: sucesso, 11 testes aprovados. -- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~DiagnosticsApiTests|FullyQualifiedName~ConfigurationValidationTests|FullyQualifiedName~MappingCompositionTests|FullyQualifiedName~InheritedMappingTests|FullyQualifiedName~NamingPolicyTests|FullyQualifiedName~ConstructorMappingTests"` - - resultado: sucesso, 68 testes aprovados. - -Validacao final: - -- `dotnet restore` - - resultado: sucesso. -- `dotnet build` - - resultado: sucesso, 0 warnings, 0 erros. -- `dotnet test` - - resultado: sucesso, 128 testes aprovados no core e 7 testes aprovados no Dommel. -- `dotnet build --configuration Release` - - resultado: sucesso, 0 warnings, 0 erros. -- `dotnet test --configuration Release` - - resultado: sucesso, 128 testes aprovados no core e 7 testes aprovados no Dommel. - -`dotnet pack` nao foi executado porque nao houve mudanca de empacotamento, metadados NuGet ou targets. - -## Limitacoes - -- `Explain()` e um snapshot por entidade, nao por consulta SQL especifica. -- Fallback do Dapper e representado de forma conservadora por propriedades publicas simples sem mapping efetivo. -- Ambiguidade de constructor overload continua sendo responsabilidade do Dapper. -- Mensagens agregadas de `Validate()` sao diagnosticas; o contrato estavel e o tipo de excecao e a agregacao, nao texto exato. -- Nao ha cache adicional para diagnosticos. - -## Dividas Fora Do Escopo - -- Roslyn analyzers -- Source generator -- AOT/trimming completo -- Nested object materialization -- Value Objects complexos -- Multiple mapping profiles por tipo -- Query-specific mapping - -## Semantic Commit - -Mensagem planejada: - -```text -feat: add mapping diagnostics API -``` diff --git a/docs/sdd/etapa-3/README.md b/docs/sdd/etapa-3/README.md deleted file mode 100644 index e1c1afc..0000000 --- a/docs/sdd/etapa-3/README.md +++ /dev/null @@ -1,77 +0,0 @@ -# Etapa 3 - -## Objetivo - -Modernizar pontos de configuracao avancada do `Dapper.FluentMap`, preservando a API publica historica e preparando evolucoes seguras em registro de mappings, constructor mapping, tipos imutaveis, validacao e diagnosticos. - -## Dependencia Das Etapas 1 E 2 - -A Etapa 3 depende das decisoes das Etapas 1 e 2 sobre `MappingRegistry`, cache estruturado, precedencia entre mappings explicitos, mappings herdados, conventions, naming policies e fallback do Dapper. - -Antes de alterar uma decisao registrada nas etapas anteriores, deve existir evidencia tecnica e a nova decisao deve ser documentada nesta pasta. - -## Leitura Obrigatoria - -Antes das proximas entregas desta etapa, leia: - -- `docs/sdd/etapa-1/README.md` -- `docs/sdd/etapa-1/decisions.md` -- `docs/sdd/etapa-1/04-mapping-registry-cache.md` -- `docs/sdd/etapa-2/README.md` -- `docs/sdd/etapa-2/status.md` -- `docs/sdd/etapa-2/decisions.md` -- `docs/sdd/etapa-2/04-naming-policies.md` -- `docs/sdd/etapa-3/README.md` -- `docs/sdd/etapa-3/status.md` -- `docs/sdd/etapa-3/decisions.md` -- o relatorio da entrega anterior nesta pasta - -## Compatibilidade Publica - -A API publica existente deve ser preservada sempre que razoavelmente possivel. APIs novas devem ser aditivas e nao devem marcar membros historicos como obsoletos sem estrategia explicita de migracao. - -## TargetFrameworks - -Os projetos de `src/` devem continuar compativeis com `netstandard2.0`. Projetos de teste devem permanecer no framework ja consolidado pelas migracoes anteriores. - -## Escopo - -Entregas: - -1. 01 - Registro e descoberta de mappings -2. 02 - Constructor mapping, records e tipos imutaveis -3. 03 - Validate e Explain - -O escopo padrao continua sendo o projeto principal `Dapper.FluentMap`. `Dapper.FluentMap.Dommel` nao deve receber alteracao funcional nesta etapa, salvo adaptacao tecnica estritamente necessaria provocada por API compartilhada. - -## Resultado da Etapa 3 - -A Etapa 3 consolidou APIs publicas aditivas para configuracao avancada e diagnostico, preservando o comportamento historico. - -Resumo: - -- novas APIs de registro: `AddMap()`, `AddMapsFromAssembly(...)` e `AddMapsFromAssemblyContaining()`; -- caminho explicito sem scanning por tipo de map, mantendo `AddMap(new CustomerMap())`; -- scanning disponivel como conveniencia, com filtros de namespace, ordenacao deterministica e deteccao de duplicidades; -- constructor mapping integrado a explicit mappings, inherited mappings, conventions e naming policies; -- records posicionais e tipos imutaveis suportados quando os parametros correspondem a propriedades simples resolvidas pelo FluentMap ou pelo fallback do Dapper; -- `Validate()` publico para validar o estado global atual com agregacao de erros; -- `Explain()` publico com modelo estruturado e provenance; -- mudancas publicas foram aditivas e nao removeram APIs historicas; -- diagnostics nao fazem I/O, nao acessam banco, nao invalidam caches e nao registram mappings. - -Limitacoes mantidas fora do escopo: - -- Roslyn analyzers; -- Source generator; -- AOT/trimming completo; -- Nested object materialization; -- Value Objects complexos; -- Multiple mapping profiles por tipo; -- Query-specific mapping. - -Relatorios: - -- `docs/sdd/etapa-3/01-mapping-registration.md` -- `docs/sdd/etapa-3/02-constructor-immutable-mapping.md` -- `docs/sdd/etapa-3/03-diagnostics-api.md` diff --git a/docs/sdd/etapa-3/decisions.md b/docs/sdd/etapa-3/decisions.md deleted file mode 100644 index 3cccdad..0000000 --- a/docs/sdd/etapa-3/decisions.md +++ /dev/null @@ -1,36 +0,0 @@ -# Decisoes Da Etapa 3 - -Registre aqui apenas decisoes que afetem entregas posteriores. - -## Registro E Descoberta De Mappings - -- `AddMap(IEntityMap)` permanece como API historica de registro por instancia. -- `AddMap()` e o caminho explicito moderno sem assembly scanning; `TMap` deve implementar exatamente uma interface fechada `IEntityMap` e possuir construtor publico sem parametros. -- `AddMap()` infere `TEntity` por reflection limitada sobre as interfaces do map e registra pelo mesmo `MappingRegistry`. -- `AddMapsFromAssembly(...)` e `AddMapsFromAssemblyContaining(...)` sao conveniencias de discovery e nao substituem o caminho explicito. -- O scanning moderno considera apenas tipos exportados, concretos e fechados que implementam `IEntityMap`. -- O scanning aceita filtros opcionais de namespace, ordena candidatos de forma deterministica e registra maps base incluidos antes dos derivados quando ambos sao descobertos juntos. -- Duplicidade de entidade, seja por registro explicito, scanning ou combinacao dos dois, e erro de configuracao; nao ha comportamento "ultimo ganha". -- Reflection restante desta entrega: inferencia de entidade via `IEntityMap`, scanning por assembly, `Activator.CreateInstance` para criar maps descobertos e criacao interna de `FluentMapTypeMap<>` no `MappingRegistry`. AOT/trimming completo permanece fora do contrato atual. - -## Constructor Mapping E Imutaveis - -- Constructor mapping do FluentMap deve traduzir metadata para o Dapper, nao materializar objetos diretamente. -- Mappings explicitos, mappings herdados, conventions e naming policies influenciam constructor selection e `IMemberMap.Parameter` quando resolvem para propriedade simples e nao ignorada. -- A selecao de construtor continua delegada ao `DefaultTypeMap`; ambiguidades e parametros opcionais seguem o comportamento do Dapper. -- A precedencia consolidada tambem vale para parametros de construtor: explicit derivado -> explicit herdado -> convention/naming policy -> Dapper default. -- Constructor parameters nao sao `MemberPath`; mappings aninhados nao participam de constructor mapping nem implicam suporte a nested object materialization. -- Records posicionais e classes imutaveis passam a funcionar quando seus parametros correspondem a propriedades simples mapeadas ou resolvidas pelo fallback do Dapper. - -## Validate E Explain - -- `Validate()` passa a ser API publica em `FluentMapper`, reaproveitando as validacoes existentes sobre o estado global atual. -- `Validate()` retorna `void`, lanca `FluentMapConfigurationException`, agrega multiplos erros quando encontrados, e deve ser idempotente e sem side effects. -- `Explain()` passa a ser API publica em `FluentMapper` e retorna modelo estruturado, nao apenas string. -- O modelo publico de diagnostico fica no namespace `Dapper.FluentMap.Diagnostics`. -- Provenance publica usa `MappingSource`: `Explicit`, `Inherited`, `Convention`, `NamingPolicy` e `DapperDefault`. -- Constructor parameter e modelado como destino adicional de um mapping simples, nao como origem/provenance. -- `Explain()` deve funcionar antes ou depois de `Initialize`; para entidade sem map/convention registrado, explica fallback do Dapper. -- O diagnostico deve ser snapshot read-only e nao deve expor dictionaries, lists mutaveis do registry ou caches internos. -- `Explain()` nao invalida cache, nao registra mappings, nao instala type maps, nao acessa banco, nao faz I/O e nao adiciona cache proprio. -- A explicacao de fallback e conservadora e nao substitui diagnostico query-specific. diff --git a/docs/sdd/etapa-3/status.md b/docs/sdd/etapa-3/status.md deleted file mode 100644 index 74ae389..0000000 --- a/docs/sdd/etapa-3/status.md +++ /dev/null @@ -1,7 +0,0 @@ -# Status Da Etapa 3 - -| Entrega | Status | Commit | -|---|---|---| -| 01 - Registro e descoberta de mappings | Concluido | feat: modernize mapping registration | -| 02 - Constructor mapping e imutaveis | Concluido | feat: support immutable constructor mappings | -| 03 - Validate e Explain | Concluido | feat: add mapping diagnostics API | diff --git a/docs/sdd/etapa-4/01-roslyn-analyzers.md b/docs/sdd/etapa-4/01-roslyn-analyzers.md deleted file mode 100644 index 103f9a4..0000000 --- a/docs/sdd/etapa-4/01-roslyn-analyzers.md +++ /dev/null @@ -1,240 +0,0 @@ -# 01 - Roslyn Analyzers - -## Specification - -Criar analyzers Roslyn para antecipar problemas de configuracao do `Dapper.FluentMap` em compile-time, sem duplicar toda a logica runtime. - -Objetivos tratados nesta primeira versao: - -- expression invalida passada para `Map(...)`; -- uso de membro nao suportado em `Map(...)`; -- mapping duplicado evidente para o mesmo `MemberPath`; -- conflito inequivoco de coluna por `ToColumn(...)` literal; -- `IncludeBase()` com tipo que nao e base class real da entidade; -- `AddMap()` com tipo que nao implementa exatamente uma interface fechada `IEntityMap` para entidade class. - -Fora do objetivo: - -- executar construtores de maps; -- instanciar mappings; -- simular assembly scanning; -- substituir `Validate()` ou as validacoes fail-fast; -- diagnosticar preferencias de estilo; -- criar Code Fix Provider; -- alterar comportamento runtime do core; -- alterar Dommel funcionalmente. - -## Discovery - -Arquivos e decisoes analisados: - -- `AGENTS.md` -- `.agents/skills/msbuild-modernization/SKILL.md` -- `.agents/skills/msbuild-antipatterns/SKILL.md` -- `.agents/skills/run-tests/SKILL.md` -- `docs/sdd/etapa-1/README.md` -- `docs/sdd/etapa-1/decisions.md` -- `docs/sdd/etapa-2/README.md` -- `docs/sdd/etapa-2/decisions.md` -- `docs/sdd/etapa-2/01-member-path.md` -- `docs/sdd/etapa-2/02-configuration-validation.md` -- `docs/sdd/etapa-3/README.md` -- `docs/sdd/etapa-3/decisions.md` -- `docs/sdd/etapa-3/01-mapping-registration.md` -- `docs/sdd/etapa-3/02-constructor-immutable-mapping.md` -- `docs/sdd/etapa-3/03-diagnostics-api.md` -- `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs` -- `src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs` -- `src/Dapper.FluentMap/Mapping/EntityMap.cs` -- `src/Dapper.FluentMap/Mapping/PropertyMap.cs` -- `src/Dapper.FluentMap/Mapping/MemberPath.cs` -- `src/Dapper.FluentMap/Mapping/PropertyMapIdentity.cs` -- `src/Dapper.FluentMap/MappingConfigurationValidator.cs` -- `src/Dapper.FluentMap/FluentMapper.cs` -- projetos `.csproj`, `Dapper.FluentMap.sln` e `NuGet.Config`. - -Catalogo de erros e classificacao: - -| Condicao | Classificacao | -|---|---| -| `Map(...)` com lambda que nao resolve para property path | Pode ser detectado estaticamente quando a lambda e literal | -| `Map(...)` usando metodo, campo, indexer ou expressao composta | Pode ser detectado estaticamente quando a lambda e literal | -| Mesmo `MemberPath` mapeado duas vezes no mesmo construtor de map | Pode ser detectado parcialmente | -| Dois paths distintos com mesmo terminal, como `Rank.Level` e `Seniority.Level` | Configuracao valida; nao diagnosticar | -| Dois mappings explicitos para a mesma coluna literal no mesmo construtor | Pode ser detectado parcialmente | -| Conflito de coluna calculada dinamicamente | Somente runtime | -| Conflito entre explicit mapping e convention | Somente runtime/predecencia existente | -| Convention ambigua ou sem `Configure(...)` | Somente runtime, pois depende de predicates e transformers | -| `ToColumn(null)` ou `ToColumn("")` literal | Detectavel, mas nao implementado nesta primeira versao para manter conjunto pequeno | -| `IncludeBase()` com interface, mesmo tipo ou tipo nao relacionado | Pode ser detectado estaticamente | -| `IncludeBase()` com base map nao registrado | Somente runtime, pois depende da ordem real de registro | -| `AddMap()` com map nao generico ou multiplas entidades | Pode ser detectado estaticamente | -| `AddMap()` com map abstrato ou sem construtor publico sem parametros | Ja e coberto pelo compilador via constraint `new()` quando aplicavel | -| `AddMapsFromAssembly(...)` com tipos descobertos invalidos | Somente runtime/reflection | -| Constructor mapping impossivel por overloads/parametros opcionais | Somente Dapper/runtime | -| Nested object materialization | Fora do contrato; nao diagnosticar apenas por `MemberPath` aninhado | - -Infraestrutura encontrada: - -- nao havia projeto de analyzer; -- nao havia Central Package Management; -- versoes de pacotes sao declaradas em cada `.csproj`; -- testes usam `net10.0`, `Microsoft.NET.Test.Sdk` e `xunit.v3`; -- solution possui folders `src` e `test`; -- `NuGet.Config` usa `nuget.org`. - -Pacotes escolhidos: - -- `Microsoft.CodeAnalysis.CSharp` `5.6.0` para o analyzer e a harness de testes; -- `Microsoft.CodeAnalysis.Analyzers` `5.6.0` no projeto de analyzer, com `PrivateAssets="all"`; -- os pacotes de teste seguem as versoes ja usadas nos testes existentes. - -Impacto futuro sobre Source Generator: - -- a leitura estatica de lambdas e cadeias `Map(...).ToColumn(...)` pode ser reaproveitada; -- o source generator nao deve assumir que todo mapping valido esta disponivel estaticamente; -- chamadas auxiliares, configuracao dinamica e assembly scanning continuam exigindo fallback runtime. - -## Decision - -Diagnostics iniciais: - -| ID | Severidade | Situacao | Detectavel estaticamente? | -|---|---|---|---| -| DFM001 | Error | `Map(...)` recebe lambda literal que nao resolve para property path de propriedades suportadas | Sim | -| DFM002 | Error | mesmo `MemberPath` aparece em duas chamadas diretas de `Map(...)` no mesmo construtor de `EntityMap` | Parcialmente, somente padrao direto | -| DFM003 | Error | dois `MemberPath`s distintos no mesmo construtor resolvem a mesma coluna literal por `ToColumn(...)` | Parcialmente, somente constantes | -| DFM004 | Error | `IncludeBase()` usa tipo que nao e base class real da entidade do map | Sim | -| DFM005 | Error | `AddMap()` usa tipo que nao implementa exatamente um `IEntityMap` fechado para entidade class | Sim | - -Severidade: - -- todos sao `Error` porque representam configuracoes que o runtime ja rejeita ou que o compilador consegue provar como invalidas; -- nenhum diagnostic de estilo foi criado. - -Code fixes: - -- nenhum Code Fix Provider foi entregue; -- corrigir expression, escolher coluna, escolher base type ou substituir map type pode alterar intencao de dominio. - -## Delivery - -Arquivos adicionados: - -- `src/Dapper.FluentMap.Analyzers/Dapper.FluentMap.Analyzers.csproj` -- `src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs` -- `src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Shipped.md` -- `src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Unshipped.md` -- `test/Dapper.FluentMap.Analyzers.Tests/Dapper.FluentMap.Analyzers.Tests.csproj` -- `test/Dapper.FluentMap.Analyzers.Tests/FluentMapConfigurationAnalyzerTests.cs` -- `docs/sdd/etapa-4/README.md` -- `docs/sdd/etapa-4/status.md` -- `docs/sdd/etapa-4/decisions.md` -- `docs/sdd/etapa-4/01-roslyn-analyzers.md` - -Arquivos alterados: - -- `Dapper.FluentMap.sln` - -Estrutura: - -```text -src/Dapper.FluentMap.Analyzers/ -test/Dapper.FluentMap.Analyzers.Tests/ -``` - -Implementacao: - -- analyzer baseado em `DiagnosticAnalyzer(LanguageNames.CSharp)`; -- usa `SyntaxNodeAction` para invocacoes e `CompilationEndAction` apenas para agregacoes locais de duplicidade/conflito; -- compara symbols para identificar APIs do FluentMap; -- interpreta lambdas literais de `Map(...)` sem executar codigo; -- aceita caminhos simples, paths aninhados e casts explicitos em torno da expressao; -- considera duplicidade/conflito apenas em statements diretos do construtor, evitando fluxo arbitrario; -- conflito de coluna exige coluna conhecida estaticamente e respeita `caseSensitive` literal; -- chamadas com coluna dinamica, bool dinamico ou `Ignore()` ficam sem diagnostic de coluna; -- `AddMap()` valida o contrato de exatamente um `IEntityMap` fechado e entidade class. - -Packaging: - -- o projeto analyzer e `netstandard2.0`; -- `IncludeBuildOutput=false`; -- o assembly do analyzer e empacotado em `analyzers/dotnet/cs`; -- `SuppressDependenciesWhenPacking=true`, evitando dependencia runtime para Roslyn no `.nuspec`; -- `PackageLicenseExpression=MIT`; -- readme minimo incluido no pacote; -- o core nao referencia o analyzer; -- o core nao recebeu dependencias Roslyn. - -## Validation - -Validacao localizada executada durante a entrega: - -- `dotnet restore .\Dapper.FluentMap.sln` - - resultado: sucesso. -- `dotnet build .\test\Dapper.FluentMap.Analyzers.Tests\Dapper.FluentMap.Analyzers.Tests.csproj --no-restore` - - resultado inicial: sucesso com warnings RS1036, RS1037, RS2008 e xUnit2031. -- Ajustes: - - `EnforceExtendedAnalyzerRules=true`; - - release tracking em `AnalyzerReleases.Shipped.md` e `AnalyzerReleases.Unshipped.md`; - - `WellKnownDiagnosticTags.CompilationEnd` em `DFM002` e `DFM003`; - - uso do overload de `Assert.Single` com predicate. -- `dotnet build .\test\Dapper.FluentMap.Analyzers.Tests\Dapper.FluentMap.Analyzers.Tests.csproj --no-restore` - - resultado: sucesso, 0 warnings, 0 erros. -- `dotnet test .\test\Dapper.FluentMap.Analyzers.Tests\Dapper.FluentMap.Analyzers.Tests.csproj --no-build` - - resultado: sucesso, 7 testes aprovados. - -Testes do analyzer cobrem: - -- positivo, mensagem, severidade e localizacao para `DFM001`; -- positivo, mensagem, severidade e localizacao para `DFM002`; -- positivo, mensagem, severidade e localizacao para `DFM003`; -- positivo, mensagem, severidade e localizacao para `DFM004`; -- positivo, mensagem, severidade e localizacao para `DFM005`; -- mapping valido sem diagnostics; -- expression valida; -- `MemberPath` aninhado valido; -- inheritance valido; -- registration valido; -- record/constructor mapping valido; -- ausencia de falso positivo em colunas com casing diferente quando ambas sao case-sensitive. - -Validacao final completa: - -- `dotnet restore` - - resultado: sucesso. -- `dotnet build` - - resultado: sucesso, 0 warnings, 0 erros. -- `dotnet test` - - resultado: sucesso, 128 testes aprovados no core, 7 no Dommel e 7 no analyzer. -- `dotnet build --configuration Release` - - resultado: sucesso, 0 warnings, 0 erros. -- `dotnet test --configuration Release` - - resultado: sucesso, 128 testes aprovados no core, 7 no Dommel e 7 no analyzer. -- `dotnet test .\test\Dapper.FluentMap.Analyzers.Tests\Dapper.FluentMap.Analyzers.Tests.csproj --no-build` - - resultado: sucesso, 7 testes aprovados. -- `dotnet pack .\src\Dapper.FluentMap.Analyzers\Dapper.FluentMap.Analyzers.csproj --configuration Release --no-build --output .\artifacts\packages` - - resultado: sucesso, pacote `Dapper.FluentMap.Analyzers.2.0.0.nupkg` criado sem warnings. -- inspecao do `.nupkg` - - resultado: contem `README.md` e `analyzers/dotnet/cs/Dapper.FluentMap.Analyzers.dll`; nao contem `lib/`. -- `dotnet list .\src\Dapper.FluentMap\Dapper.FluentMap.csproj package --include-transitive` - - resultado: o projeto principal continua com `Dapper` como unica dependencia direta; nenhuma dependencia Roslyn foi adicionada ao core. - -Confirmacoes: - -- core nao referencia Roslyn; -- pacote principal nao recebe dependencias Roslyn; -- analyzer nao muda runtime; -- diagnostics aparecem apenas nos cenarios cobertos; -- suite anterior continua verde. - -## Limitacoes - -- Nao analisa chamadas `Map(...)` indiretas por helper method. -- Nao analisa duplicidade em fluxos condicionais, loops ou chamadas fora de statements diretos do construtor. -- Nao executa constructor mapping nem simula o Dapper. -- Nao diagnostica `AddMapsFromAssembly(...)`, pois discovery depende de reflection e ambiente runtime. -- Nao diagnostica base map ausente em `IncludeBase()`, pois depende de registro real. -- Nao diagnostica transformers de naming policy ou convention. -- Nao diagnostica materializacao aninhada. -- Nao ha Code Fix Provider. diff --git a/docs/sdd/etapa-4/02-trimming-aot.md b/docs/sdd/etapa-4/02-trimming-aot.md deleted file mode 100644 index 5e7ecd6..0000000 --- a/docs/sdd/etapa-4/02-trimming-aot.md +++ /dev/null @@ -1,326 +0,0 @@ -# 02 - Trimming E Native AOT - -## Specification - -Esta entrega mediu e melhorou a compatibilidade do core `Dapper.FluentMap` com IL trimming, single-file e Native AOT, preservando o target `netstandard2.0`. - -Objetivos tratados: - -- medir baseline de publish trimmed e Native AOT em um consumidor pequeno; -- separar o caminho explicito de registro do caminho por assembly scanning; -- remover reflection redundante no registro do type map interno; -- adicionar annotations de trimming quando o contrato e verificavel; -- marcar APIs de scanning como dependentes de reflection; -- documentar warnings de propriedade do FluentMap e do Dapper. - -Fora do objetivo: - -- declarar compatibilidade Native AOT completa; -- alterar o target do core para `net10.0`; -- criar source generator; -- corrigir warnings internos do Dapper; -- tornar assembly scanning seguro a qualquer custo. - -## Discovery - -Arquivos e contexto analisados: - -- `AGENTS.md` -- `.agents/skills/dotnet-aot-compat/SKILL.md` -- `.agents/skills/dotnet-aot-compat/references/polyfills.md` -- `.agents/skills/run-tests/SKILL.md` -- `.agents/skills/msbuild-antipatterns/SKILL.md` -- `docs/sdd/etapa-1/README.md` -- `docs/sdd/etapa-1/decisions.md` -- `docs/sdd/etapa-2/README.md` -- `docs/sdd/etapa-2/decisions.md` -- `docs/sdd/etapa-3/README.md` -- `docs/sdd/etapa-3/decisions.md` -- `docs/sdd/etapa-3/01-mapping-registration.md` -- `docs/sdd/etapa-4/README.md` -- `docs/sdd/etapa-4/status.md` -- `docs/sdd/etapa-4/decisions.md` -- `docs/sdd/etapa-4/01-roslyn-analyzers.md` -- `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs` -- `src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs` -- `src/Dapper.FluentMap/Mapping/EntityMap.cs` -- `src/Dapper.FluentMap/MappingRegistry.cs` -- `src/Dapper.FluentMap/TypeMaps/*` -- `src/Dapper.FluentMap/Utils/FluentMapConfigurationExtensions.cs` -- `src/Dapper.FluentMap/Utils/ReflectionHelper.cs` - -Entrega 1 confirmada: - -- `docs/sdd/etapa-4/status.md` registrava `01 - Roslyn Analyzers` como `Concluido`; -- `docs/sdd/etapa-4/01-roslyn-analyzers.md` registrava validacao completa e pacote de analyzer inspecionado. - -Busca executada: - -```text -rg -n "Assembly|GetTypes|GetExportedTypes|GetMember|GetProperty|GetConstructor|Activator\.CreateInstance|MakeGenericMethod|\.Invoke\(|CreateDelegate|Dynamic|Expression\.Compile|Type\.GetType|RuntimeTypeHandle|MakeGenericType|PropertyInfo|MemberInfo" src test docs\sdd\etapa-4 -g "*.cs" -g "*.csproj" -g "*.md" -``` - -Classificacao dos usos relevantes no core: - -| Uso | Local | Classificacao | Decisao | -|---|---|---|---| -| `Assembly.GetExportedTypes()` | `FluentMapConfiguration.AddMapsFromAssembly(...)` | Reflection-dependent por design | API marcada com `RequiresUnreferencedCode`; scanning documentado como trimming-sensitive | -| `Assembly.GetCallingAssembly().GetExportedTypes()` | `FluentConventionConfiguration.ForEntitiesInCurrentAssembly(...)` | Reflection-dependent por design | API marcada com `RequiresUnreferencedCode` | -| `Assembly.GetExportedTypes()` para conventions | `FluentConventionConfiguration.ForEntitiesInAssembly(...)` | Reflection-dependent por design | API marcada com `RequiresUnreferencedCode` | -| `Assembly.GetTypes()` | `FluentMapConfigurationExtensions.ApplyMapsFromAssemblies(...)` | Reflection-dependent por design | API legada marcada com `RequiresUnreferencedCode` | -| `Activator.CreateInstance(mapType)` | Scanning de maps | Trimming-sensitive | Mantido apenas no caminho de scanning e coberto pelo aviso da API | -| `Activator.CreateInstance(typeof(FluentMapTypeMap<>).MakeGenericType(type))` | `MappingRegistry.SetDapperTypeMap(...)` | Pode ser removido | Substituido por type map interno nao generico | -| `MakeGenericMethod(...).Invoke(...)` | `ApplyMapsFromAssemblies(...)` legado | Reflection-dependent por design | Mantido por compatibilidade e marcado como trimming-sensitive | -| `Type.GetInterfaces()` | `AddMap()` | Trimming-sensitive, anotavel | `TMap` anotado com preservacao de `Interfaces` | -| `Type.GetProperties(...)` | `ForEntity()` e `Explain()` | Trimming-sensitive, anotavel | `ForEntity()`, `MapProperties(...)`, `Explain()` e helpers anotados | -| `Type.GetConstructors(...)` | `Explain()` | Trimming-sensitive, anotavel | `Explain()` e helpers anotados | -| `PropertyInfo` / `MemberInfo` via expression tree | `ReflectionHelper`, `MemberPath`, `PropertyMap` | Reflection metadata por contrato | Mantido; nao faz scanning nem busca ampla por nome | -| `ConstructorInfo` / `ParameterInfo` recebidos do Dapper | `FluentConstructorTypeMap` | AOT-safe no FluentMap; depende do Dapper para discovery | Mantido; warnings restantes classificados como dependency-owned | - -Areas especiais: - -- Assembly scanning: permanece convenience reflection-dependent. -- Registro generico: `AddMap()` e o caminho recomendado; usa reflection limitada para inferir `IEntityMap`, agora anotada e sem warning do FluentMap no smoke trimmed explicito. -- Constructor mapping: nao materializa objetos; traduz metadata para o Dapper. Warnings de discovery de construtor no smoke trimmed vem de `Dapper.DefaultTypeMap`. -- MemberPath: usa `PropertyInfo` obtido da expression ou de convention ja configurada; nao adiciona scanning. -- Convention discovery: `ForEntity()` e o caminho explicito anotado; `ForEntitiesInAssembly(...)` e `ForEntitiesInCurrentAssembly(...)` continuam dependentes de scanning. -- Analyzer: fica isolado em `Dapper.FluentMap.Analyzers` e nao altera runtime do core. -- `Explain()`: faz diagnostico por reflection sobre propriedades/construtores publicos e foi anotado. - -## Baseline - -Ambiente: - -- SDK: `10.0.302` -- Runtime alvo do consumidor smoke: `net10.0` -- RID usado: `win-x64` -- Core: `netstandard2.0` - -Baseline por `ProjectReference`: - -```text -dotnet publish ... -p:PublishTrimmed=true -``` - -Resultado: - -- falhou antes da analise do consumidor com `NETSDK1124`, porque `PublishTrimmed` foi propagado ao projeto `src/Dapper.FluentMap` `netstandard2.0`; -- `dotnet publish ... -p:PublishAot=true` falhou com `NETSDK1207` pelo mesmo motivo conceitual. - -Baseline usando referencia direta ao assembly compilado do core: - -```text -dotnet build .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release -dotnet publish --configuration Release --runtime win-x64 --self-contained true -p:PublishTrimmed=true -p:TrimMode=full -p:PublishAot=false -p:TrimmerSingleWarn=false -p:ILLinkTreatWarningsAsErrors=false -``` - -Resultado trimmed antes das mudancas: - -- publish concluido; -- runtime executou `explicit:Id` e `scanning:Id` no consumidor combinado; -- warnings FluentMap-owned: - - `IL2067` em `FluentMapConfiguration.CreateEntityMap(Type)` por `Activator.CreateInstance(Type)`; - - `IL2026` em `FluentMapConfiguration.GetExportedTypes(Assembly)` por `Assembly.GetExportedTypes()`; - - `IL2070` em `FluentMapConfiguration.GetMappedEntityType(Type)` por `Type.GetInterfaces()`. -- warnings dependency-owned: - - `IL2046`, `IL2092`, `IL2075`, `IL2070` em fontes do Dapper. - -Baseline Native AOT antes das mudancas: - -```text -dotnet publish --configuration Release --runtime win-x64 --self-contained true -p:PublishAot=true -``` - -Resultado: - -- build gerou o DLL intermediario do consumidor; -- publish falhou no ambiente com `Platform linker not found`; -- runtime Native AOT nao foi validado porque faltam os pre-requisitos de toolchain C++ para Native AOT no Windows. - -## Decision - -Registro explicito: - -- `AddMap()` permanece a API explicita moderna; -- `TMap` recebeu annotation para preservar interfaces, permitindo a inferencia de `IEntityMap` sem warning do FluentMap no smoke trimmed explicito; -- a instancia do map passou a ser criada por `new TMap()`, removendo `Activator.CreateInstance` do caminho explicito. - -Assembly scanning: - -- `AddMapsFromAssembly(...)`, `AddMapsFromAssemblyContaining()`, `ForEntitiesInAssembly(...)`, `ForEntitiesInCurrentAssembly(...)` e `ApplyMapsFromAssemblies(...)` foram marcados com `RequiresUnreferencedCode`; -- scanning continua suportado em runtime normal; -- scanning trimmed pode falhar em runtime quando o trimmer remove tipos, interfaces ou construtores que so seriam descobertos por reflection; -- nao foi usado `UnconditionalSuppressMessage`, `NoWarn`, `SuppressTrimAnalysisWarnings` ou `#pragma`. - -Type map interno: - -- o registry deixou de criar `FluentMapTypeMap` por `MakeGenericType` + `Activator.CreateInstance`; -- foi adicionado um type map interno nao generico para registro no Dapper; -- a classe publica `FluentMapTypeMap` permanece para compatibilidade. - -Annotations: - -- polyfills internos foram adicionados para manter `netstandard2.0`; -- `DynamicallyAccessedMembers` foi usado apenas onde o fluxo e verificavel; -- `RequiresUnreferencedCode` foi usado em APIs de scanning e helpers privados exclusivos desse caminho; -- `Explain()` foi anotado porque enumera propriedades e construtores publicos. - -Impacto publico: - -- nenhuma API publica foi removida; -- annotations em APIs publicas passam a expor warnings corretos para consumidores trimmed/AOT; -- scanning agora avisa o consumidor em publish trimmed/AOT em vez de esconder a fragilidade. - -## Delivery - -Arquivos adicionados: - -- `src/Dapper.FluentMap/Compatibility/CodeAnalysisAttributes.cs` -- `src/Dapper.FluentMap/TypeMaps/FluentMapTypeMap.cs` -- `test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj` -- `test/Dapper.FluentMap.AotSmoke/Program.cs` -- `docs/sdd/etapa-4/02-trimming-aot.md` - -Arquivos alterados: - -- `Dapper.FluentMap.sln` -- `README.md` -- `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs` -- `src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs` -- `src/Dapper.FluentMap/FluentMapper.cs` -- `src/Dapper.FluentMap/Mapping/EntityMap.cs` -- `src/Dapper.FluentMap/MappingRegistry.cs` -- `src/Dapper.FluentMap/Utils/FluentMapConfigurationExtensions.cs` -- `docs/sdd/etapa-4/decisions.md` -- `docs/sdd/etapa-4/status.md` - -Comportamento preservado: - -- core continua `netstandard2.0`; -- `AddMap(new CustomerMap())` permanece; -- `AddMap()` permanece; -- assembly scanning continua funcionando em runtime normal; -- conventions explicitas e naming policies continuam funcionando; -- constructor mapping continua delegado ao Dapper. - -Comportamento/documentacao alterados: - -- APIs de scanning emitem warning de trimming/AOT via `RequiresUnreferencedCode`; -- registro explicito nao emite warnings FluentMap-owned no smoke trimmed; -- registry nao depende mais de `MakeGenericType` + `Activator.CreateInstance` para instalar o type map interno. - -## Validation - -Validacao localizada executada durante a entrega: - -```text -dotnet build .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release --no-restore -``` - -Resultado: - -- sucesso, 0 warnings, 0 erros. - -Smoke normal: - -```text -dotnet run --project .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release -dotnet run --project .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:DefineConstants=AOT_SMOKE_SCANNING -``` - -Resultado: - -- `explicit:ok`; -- `scanning:ok`. - -Publish trimmed explicito: - -```text -dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishTrimmed=true -p:TrimMode=full -p:PublishAot=false -p:TrimmerSingleWarn=false -p:ILLinkTreatWarningsAsErrors=false -.\test\Dapper.FluentMap.AotSmoke\bin\Release\net10.0\win-x64\publish\Dapper.FluentMap.AotSmoke.exe -``` - -Resultado: - -- publish concluido; -- runtime: `explicit:ok`; -- 0 warnings FluentMap-owned; -- warnings restantes dependency-owned no Dapper: `IL2080`, `IL2046`, `IL2092`, `IL2075`, `IL2070`. - -Publish trimmed scanning: - -```text -dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishTrimmed=true -p:TrimMode=full -p:PublishAot=false -p:DefineConstants=AOT_SMOKE_SCANNING -p:TrimmerSingleWarn=false -p:ILLinkTreatWarningsAsErrors=false -.\test\Dapper.FluentMap.AotSmoke\bin\Release\net10.0\win-x64\publish\Dapper.FluentMap.AotSmoke.exe -``` - -Resultado: - -- publish concluido; -- warning FluentMap-owned esperado: `IL2026` na chamada de `AddMapsFromAssemblyContaining()`; -- runtime falhou: `Column 'customer_id' was not mapped to property 'Id'.`; -- falha confirma que scanning depende de metadata que pode ser removida pelo trimmer. - -Publish Native AOT explicito: - -```text -dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishAot=true -p:TrimmerSingleWarn=false -p:ILLinkTreatWarningsAsErrors=false -p:MSBuildWarningsAsMessages= -``` - -Resultado: - -- build gerou `Dapper.FluentMap.AotSmoke.dll`; -- publish falhou com `Platform linker not found`; -- runtime Native AOT nao foi validado neste ambiente. - -Validacao final completa: - -```text -dotnet restore -dotnet build -dotnet test -dotnet build --configuration Release -dotnet test --configuration Release -dotnet pack .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release --no-build --output .\artifacts\packages -``` - -Resultado: - -- `dotnet restore`: sucesso; -- `dotnet build`: sucesso, 0 warnings, 0 erros; -- `dotnet test`: sucesso, 128 testes do core, 7 testes Dommel e 7 testes do analyzer; -- `dotnet build --configuration Release`: sucesso, 0 warnings, 0 erros; -- `dotnet test --configuration Release`: sucesso, 128 testes do core, 7 testes Dommel e 7 testes do analyzer. -- `dotnet pack`: pacote `Dapper.FluentMap.2.0.0.nupkg` criado; warning existente `NU5125` sobre `PackageLicenseUrl` legado. - -Inspecao do pacote: - -- contem `lib/netstandard2.0/Dapper.FluentMap.dll`; -- contem `lib/netstandard2.0/Dapper.FluentMap.xml`; -- nuspec mantem dependencia `Dapper` `2.1.79` para `.NETStandard2.0`; -- nao contem projetos de teste nem o smoke app. - -Confirmacoes: - -- `src/Dapper.FluentMap/Dapper.FluentMap.csproj` continua com `TargetFrameworks` igual a `netstandard2.0`; -- registro explicito nao ganhou assembly scanning; -- nenhum warning foi silenciado por `NoWarn`, `SuppressTrimAnalysisWarnings`, `UnconditionalSuppressMessage` ou `#pragma`; -- APIs reflection-heavy estao anotadas e documentadas; -- Dommel nao recebeu alteracao funcional. - -## Matriz - -| Funcionalidade | Normal | Trimmed | Native AOT | Observacao | -|---|---|---|---|---| -| Registro explicito | Suportado | Publica e executa no smoke; sem warnings FluentMap-owned | Publish bloqueado pelo linker ausente; sem runtime validado | Caminho recomendado para trimmed/AOT | -| Assembly scanning | Suportado | Publica com `IL2026` e falha no smoke scanning trimmed | Nao validado em runtime; tratado como reflection-dependent | Mantido como convenience, nao como caminho AOT-friendly | -| Naming policies | Suportado via `ForEntity()` | Validado no smoke explicito trimmed | Nao validado em runtime | `ForEntitiesInAssembly(...)` segue trimming-sensitive | -| Constructor mapping | Suportado | Validado no smoke explicito trimmed; warnings restantes vem do Dapper | Nao validado em runtime | FluentMap traduz metadata; Dapper faz discovery final | - -## Limitacoes Restantes - -- Native AOT runtime nao foi executado porque o ambiente nao possui o linker C++ exigido pelo SDK. -- Dapper ainda emite warnings de trimming/AOT no smoke; esses warnings nao pertencem ao FluentMap e nao foram corrigidos internamente. -- Assembly scanning nao e seguro sob trimming por contrato; a Entrega 3 pode substituir esse caminho por metadata gerada. -- `PropertyInfo` e `MemberInfo` continuam parte do contrato publico e da integracao com Dapper. -- O projeto smoke usa referencia direta ao assembly compilado do core durante publish trimmed/AOT para evitar propagacao de `PublishTrimmed`/`PublishAot` ao projeto `netstandard2.0`; os comandos de publish devem ser precedidos por build do core em `Release`. diff --git a/docs/sdd/etapa-4/03-source-generator.md b/docs/sdd/etapa-4/03-source-generator.md deleted file mode 100644 index 199a634..0000000 --- a/docs/sdd/etapa-4/03-source-generator.md +++ /dev/null @@ -1,396 +0,0 @@ -# 03 - Source Generator - -## Specification - -Esta entrega avalia e implementa um Source Generator incremental para reduzir reflection no registro de mappings, sem substituir o runtime nem remover caminhos existentes. - -Objetivos tratados: - -- descobrir em compile-time classes de mapping declaradas na compilacao atual; -- gerar chamadas explicitas para `FluentMapConfiguration.AddMap()`; -- evitar `Assembly.GetTypes`, `Assembly.GetExportedTypes` e `Activator.CreateInstance(Type)` no caminho gerado; -- preservar registro manual e assembly scanning; -- manter o core `Dapper.FluentMap` sem dependencia Roslyn; -- validar o caminho gerado com Dapper, naming policies, inheritance e constructor mapping; -- documentar limites reais de trimming e Native AOT sem declarar suporte nao validado. - -Fora do objetivo: - -- materializador gerado; -- leitura gerada de `DbDataReader`; -- SQL, CRUD, query wrappers ou ORM; -- nested object construction; -- converters; -- suporte automatico a todo o grafo de assemblies referenciados. - -Experiencia desejada: - -```csharp -using Dapper.FluentMap; - -FluentMapper.Initialize(configuration => -{ - configuration.AddGeneratedMappings(); -}); -``` - -Codigo gerado conceitual: - -```csharp -configuration - .AddMap() - .AddMap(); -``` - -O generator e opcional. A biblioteca continua funcional sem ele. - -## Discovery - -Arquivos e contexto analisados: - -- `AGENTS.md` -- `.agents/skills/run-tests/SKILL.md` -- `.agents/skills/msbuild-modernization/SKILL.md` -- `.agents/skills/msbuild-antipatterns/SKILL.md` -- `.agents/skills/dotnet-aot-compat/SKILL.md` -- `.agents/skills/msbuild-antipatterns/references/private-assets.md` -- `docs/sdd/etapa-1/README.md` -- `docs/sdd/etapa-1/decisions.md` -- `docs/sdd/etapa-2/README.md` -- `docs/sdd/etapa-2/decisions.md` -- `docs/sdd/etapa-3/README.md` -- `docs/sdd/etapa-3/decisions.md` -- `docs/sdd/etapa-3/01-mapping-registration.md` -- `docs/sdd/etapa-4/README.md` -- `docs/sdd/etapa-4/status.md` -- `docs/sdd/etapa-4/decisions.md` -- `docs/sdd/etapa-4/01-roslyn-analyzers.md` -- `docs/sdd/etapa-4/02-trimming-aot.md` -- `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs` -- `src/Dapper.FluentMap/Mapping/EntityMap.cs` -- `src/Dapper.FluentMap/MappingRegistry.cs` -- `src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs` -- projetos e testes existentes. - -Entregas anteriores da Etapa 4: - -- Entrega 01 - Roslyn Analyzers: `Concluido`, commit local `9075f61`. -- Entrega 02 - Trimming e Native AOT: `Concluido`, commit local `d559d65`. - -Respostas de discovery: - -1. Como identificar um mapping em compile-time: - - um `class` symbol declarado na compilacao atual que implemente uma interface fechada `Dapper.FluentMap.Mapping.IEntityMap`. -2. Tipo base/interface que representa mapping: - - `IEntityMap` e a interface nao generica `IEntityMap`; `EntityMap` e `EntityMapBase` sao bases comuns, mas a decisao usa a interface fechada para nao depender da hierarquia concreta. -3. Classes de mapping: - - abstratas: nao registraveis pelo caminho gerado; reportadas por `DFM006` e ignoradas; - - genericas abertas: nao registraveis pelo caminho gerado; reportadas por `DFM006` e ignoradas; - - nested: suportadas quando a classe e todos os containing types sao `public` ou `internal`; - - internal: suportadas quando possuem construtor publico sem parametros; - - private/protected/file-local: nao acessiveis pelo codigo gerado top-level; reportadas por `DFM006` e ignoradas; - - sem construtor publico sem parametros: reportadas por `DFM006` e ignoradas. -4. Como `AddMap()` funciona: - - `TMap` deve implementar `IEntityMap`, possuir `new()`, e implementar exatamente uma interface fechada `IEntityMap`; - - a entidade e inferida pelo runtime a partir das interfaces; - - a instancia e criada por `new TMap()` e registrada pelo mesmo `MappingRegistry`. -5. Constraints: - - `where TMap : IEntityMap, new()`; - - entidade alvo deve ser `class`; - - runtime ainda valida duplicidade, colunas, `IncludeBase()`, composition e instalacao do type map do Dapper. -6. Duplicidades: - - o generator detecta duas classes geraveis para a mesma entidade na compilacao atual e reporta `DFM007`; - - o runtime continua autoridade para duplicidade causada por registro manual, scanning, ordem dinamica ou assemblies externos. -7. Ativacao: - - instalacao/referencia do pacote/projeto `Dapper.FluentMap.Generators` como analyzer/source generator. -8. Assembly do codigo gerado: - - o codigo e gerado dentro do assembly do consumidor onde o generator esta executando. -9. Assemblies referenciados: - - nao sao descobertos nesta entrega. -10. Como evitar geracao duplicada: - - partial declarations sao agrupadas por nome simbolico do map; - - a saida possui hint name unico `DapperFluentMapGeneratedRegistration.g.cs`; - - mapas sao ordenados deterministicamente por profundidade de heranca da entidade, nome da entidade e nome do map. - -## Decision - -Foi criado o projeto separado: - -```text -src/Dapper.FluentMap.Generators/ -``` - -Motivos: - -- manter o core sem Roslyn; -- permitir opt-in separado do pacote runtime; -- empacotar o assembly em `analyzers/dotnet/cs`; -- evitar dependencia circular com `Dapper.FluentMap.Analyzers`. - -Nao foi criado projeto comum entre analyzer e generator. A duplicacao atual e pequena: identificacao de `IEntityMap` e descriptor `DFM005`. Um projeto comum so seria justificado quando houver compartilhamento maior e estavel. - -API gerada: - -```csharp -namespace Dapper.FluentMap -{ - internal static class DapperFluentMapGeneratedRegistration - { - public static FluentMapConfiguration AddGeneratedMappings( - this FluentMapConfiguration configuration); - } -} -``` - -Caracteristicas: - -- namespace `Dapper.FluentMap`, pois consumidores normalmente ja importam esse namespace para `FluentMapper`; -- classe `internal`, reduzindo superficie publica do assembly consumidor; -- metodo extension acessivel dentro do assembly consumidor; -- null check em `configuration`; -- retorno da propria configuracao para composicao fluente; -- chamadas fully-qualified a `AddMap()`; -- nenhum `using` fragil; -- nenhum reflection, scanning ou estado capturado no codigo gerado. - -Escopo de descoberta: - -- somente maps declarados na compilacao atual; -- nenhum traversal automatico de references. - -Motivos para nao atravessar references: - -- custo; -- determinismo; -- duplicidade entre assemblies; -- regras de visibilidade; -- risco de surpresa para o consumidor; -- possivel ambiguidade de multiplos assemblies gerando o mesmo extension method. - -Diagnostics: - -| ID | Severidade | Situacao | -|---|---|---| -| DFM005 | Error | tipo candidato implementa zero/multiplas interfaces fechadas `IEntityMap` ou entidade alvo nao e class | -| DFM006 | Info | mapping candidato nao entra na geracao por ser abstrato, generico aberto, inacessivel ou sem construtor publico sem parametros | -| DFM007 | Error | mais de um mapping geravel para a mesma entidade na compilacao atual | - -`DFM005` foi reutilizado porque a regra semantica e a mesma do analyzer de `AddMap()`: o tipo nao satisfaz o contrato de registro generico. - -Comparacao de estrategias: - -| Estrategia | Reflection | Trimming | AOT | Manutencao manual | -|---|---|---|---|---| -| Registro manual | Nao usa scanning; `AddMap()` ainda infere `IEntityMap` por metadata anotada | Smoke explicit trimmed publica e executa; 0 warnings FluentMap-owned | Publish bloqueado no ambiente por linker C++ ausente; runtime nao validado | Alta: cada map precisa ser listado | -| Registro gerado | Codigo gerado nao usa reflection; chama `AddMap()` | Smoke generated trimmed publica e executa; 0 warnings FluentMap-owned | Mesmo bloqueio de linker do ambiente; runtime nao validado | Baixa dentro do assembly atual | -| Assembly scanning | Usa `Assembly.GetExportedTypes`/`GetTypes` e `Activator.CreateInstance(Type)` | Smoke scanning trimmed emite `IL2026` FluentMap-owned e falha em runtime | Nao validado; tratado como reflection-dependent | Baixa | - -## Delivery - -Arquivos adicionados: - -- `src/Dapper.FluentMap.Generators/Dapper.FluentMap.Generators.csproj` -- `src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs` -- `src/Dapper.FluentMap.Generators/README.md` -- `src/Dapper.FluentMap.Generators/AnalyzerReleases.Shipped.md` -- `src/Dapper.FluentMap.Generators/AnalyzerReleases.Unshipped.md` -- `test/Dapper.FluentMap.Generators.Tests/Dapper.FluentMap.Generators.Tests.csproj` -- `test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs` -- `test/Dapper.FluentMap.GeneratedRegistration.Tests/Dapper.FluentMap.GeneratedRegistration.Tests.csproj` -- `test/Dapper.FluentMap.GeneratedRegistration.Tests/AssemblyInfo.cs` -- `test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs` -- `docs/sdd/etapa-4/03-source-generator.md` - -Arquivos alterados: - -- `Dapper.FluentMap.sln` -- `README.md` -- `src/Dapper.FluentMap/Properties/AssemblyInfo.cs` -- `test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj` -- `test/Dapper.FluentMap.AotSmoke/Program.cs` -- `docs/sdd/etapa-4/README.md` -- `docs/sdd/etapa-4/status.md` -- `docs/sdd/etapa-4/decisions.md` - -Implementacao: - -- generator incremental por `IIncrementalGenerator`; -- `SyntaxProvider` filtra `class` com base list e valida por semantic model; -- descoberta usa symbols, nao executa codigo do consumidor; -- maps abstratos, genericos abertos, inacessiveis ou sem construtor publico sem parametros sao ignorados com diagnostic informativo; -- duplicidade de entidade no conjunto geravel falha com diagnostic `DFM007`; -- partial declarations sao deduplicadas; -- saida deterministica por ordenacao estavel; -- codigo gerado contem header `// ` e `GeneratedCodeAttribute`; -- codigo gerado usa nomes fully-qualified; -- codigo gerado nao usa reflection. - -Testes unitarios do generator cobrem: - -- zero mappings; -- um mapping; -- varios mappings; -- mapping internal suportado; -- mapping abstrato; -- mapping generico aberto; -- duplicidade; -- namespaces distintos; -- mesmo nome de classe em namespaces diferentes; -- saida deterministica; -- recompilacao incremental em execucoes repetidas do driver; -- codigo gerado compila. - -Teste de integracao cobre: - -- `AddGeneratedMappings()` executado em projeto real com generator como analyzer; -- materializacao real via Dapper e SQLite in-memory; -- mapping internal; -- inheritance por `IncludeBase()`; -- constructor mapping; -- naming policy `SnakeCase` coexistindo com registro gerado. - -Smoke AOT: - -- o projeto `Dapper.FluentMap.AotSmoke` recebeu caminho `AOT_SMOKE_GENERATED`; -- o generator e referenciado como analyzer somente quando `DefineConstants=AOT_SMOKE_GENERATED`. - -Packaging: - -- `Dapper.FluentMap.Generators` empacota apenas `analyzers/dotnet/cs/Dapper.FluentMap.Generators.dll` e `README.md`; -- `SuppressDependenciesWhenPacking=true`; -- pacotes Roslyn usam `PrivateAssets="all"`; -- core nao referencia Roslyn; -- nenhum pacote Roslyn vira dependencia runtime do core. - -## Validation - -Ambiente: - -- SDK: `10.0.302` -- test runner detectado: VSTest com xUnit v3 -- core: `netstandard2.0` -- testes: `net10.0` -- projeto generator: `netstandard2.0` - -Validacao localizada executada: - -```text -dotnet build .\src\Dapper.FluentMap.Generators\Dapper.FluentMap.Generators.csproj -dotnet build .\test\Dapper.FluentMap.Generators.Tests\Dapper.FluentMap.Generators.Tests.csproj -dotnet test .\test\Dapper.FluentMap.Generators.Tests\Dapper.FluentMap.Generators.Tests.csproj --no-build -dotnet test .\test\Dapper.FluentMap.GeneratedRegistration.Tests\Dapper.FluentMap.GeneratedRegistration.Tests.csproj -dotnet test .\test\Dapper.FluentMap.Analyzers.Tests\Dapper.FluentMap.Analyzers.Tests.csproj --no-build -``` - -Resultados locais ja observados: - -- build do generator: sucesso, 0 warnings, 0 erros; -- build dos testes do generator: sucesso, 0 warnings, 0 erros; -- testes do generator: sucesso, 12 testes aprovados; -- testes de integracao do registro gerado: sucesso, 1 teste aprovado; -- testes do analyzer: sucesso, 7 testes aprovados. - -Validacao final completa: - -```text -dotnet restore -dotnet build -dotnet test -dotnet build --configuration Release -dotnet test --configuration Release -dotnet test --configuration Release --no-build -``` - -Resultado: - -- `dotnet restore`: sucesso; -- `dotnet build`: sucesso, 0 warnings, 0 erros; -- `dotnet test`: sucesso, 128 testes do core, 7 Dommel, 7 analyzer, 12 generator e 1 generated-registration integration; -- `dotnet build --configuration Release`: sucesso, 0 warnings, 0 erros; -- `dotnet test --configuration Release`: sucesso com os mesmos 155 testes; -- `dotnet test --configuration Release --no-build`: sucesso com os mesmos 155 testes. - -Smokes normais: - -```text -dotnet run --project .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release -dotnet run --project .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:DefineConstants=AOT_SMOKE_SCANNING -dotnet run --project .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:DefineConstants=AOT_SMOKE_GENERATED -``` - -Resultado: - -- `explicit:ok`; -- `scanning:ok`; -- `generated:ok`. - -Publish trimmed: - -```text -dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishTrimmed=true -p:TrimMode=full -p:PublishAot=false -p:TrimmerSingleWarn=false -p:ILLinkTreatWarningsAsErrors=false -.\test\Dapper.FluentMap.AotSmoke\bin\Release\net10.0\win-x64\publish\Dapper.FluentMap.AotSmoke.exe - -dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishTrimmed=true -p:TrimMode=full -p:PublishAot=false -p:DefineConstants=AOT_SMOKE_GENERATED -p:TrimmerSingleWarn=false -p:ILLinkTreatWarningsAsErrors=false -.\test\Dapper.FluentMap.AotSmoke\bin\Release\net10.0\win-x64\publish\Dapper.FluentMap.AotSmoke.exe - -dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishTrimmed=true -p:TrimMode=full -p:PublishAot=false -p:DefineConstants=AOT_SMOKE_SCANNING -p:TrimmerSingleWarn=false -p:ILLinkTreatWarningsAsErrors=false -.\test\Dapper.FluentMap.AotSmoke\bin\Release\net10.0\win-x64\publish\Dapper.FluentMap.AotSmoke.exe -``` - -Resultado: - -- explicit trimmed: publish concluido, runtime `explicit:ok`, 0 warnings FluentMap-owned; -- generated trimmed: publish concluido, runtime `generated:ok`, 0 warnings FluentMap-owned; -- scanning trimmed: publish concluido com `IL2026` FluentMap-owned esperado em `AddMapsFromAssemblyContaining()`; runtime falhou com `Column 'customer_id' was not mapped to property 'Id'.`; -- warnings restantes nos caminhos explicit/generated pertencem ao Dapper (`DefaultTypeMap`, `CustomPropertyTypeMap`, `DapperRow` e helpers internos). - -Native AOT: - -```text -dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishAot=true -p:TrimmerSingleWarn=false -p:ILLinkTreatWarningsAsErrors=false -p:MSBuildWarningsAsMessages= -dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishAot=true -p:DefineConstants=AOT_SMOKE_GENERATED -p:TrimmerSingleWarn=false -p:ILLinkTreatWarningsAsErrors=false -p:MSBuildWarningsAsMessages= -``` - -Resultado: - -- explicit AOT: falhou com `Platform linker not found`; -- generated AOT: falhou com `Platform linker not found`; -- runtime Native AOT nao foi validado neste ambiente. - -Pack e inspecao: - -```text -dotnet pack .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release --no-build --output .\artifacts\packages -dotnet pack .\src\Dapper.FluentMap.Generators\Dapper.FluentMap.Generators.csproj --configuration Release --no-build --output .\artifacts\packages -dotnet pack .\src\Dapper.FluentMap.Analyzers\Dapper.FluentMap.Analyzers.csproj --configuration Release --no-build --output .\artifacts\packages -dotnet list .\src\Dapper.FluentMap\Dapper.FluentMap.csproj package --include-transitive -dotnet list .\src\Dapper.FluentMap.Generators\Dapper.FluentMap.Generators.csproj package --include-transitive -``` - -Resultado: - -- `Dapper.FluentMap.2.0.0.nupkg` criado; warning existente `NU5125` sobre `PackageLicenseUrl`; -- `Dapper.FluentMap.Generators.2.0.0.nupkg` criado; -- `Dapper.FluentMap.Analyzers.2.0.0.nupkg` criado; -- pacote generator contem `README.md` e `analyzers/dotnet/cs/Dapper.FluentMap.Generators.dll`, sem `lib/`; -- nuspec do generator nao contem grupo de dependencias; -- pacote analyzer continua contendo apenas `README.md` e `analyzers/dotnet/cs/Dapper.FluentMap.Analyzers.dll`, sem `lib/`; -- pacote core contem apenas `lib/netstandard2.0/Dapper.FluentMap.dll` e XML docs; -- core continua com `Dapper` como unica dependencia direta e nenhuma dependencia Roslyn. - -Limitacoes restantes: - -- mappings em assemblies referenciados nao sao descobertos automaticamente; -- o extension method gerado e `internal`, portanto o assembly que declara os maps deve chamar seu proprio `AddGeneratedMappings()`; -- abstract maps e open generic maps sao ignorados em vez de registrados; -- ordem por inheritance depth cobre o caso esperado de base maps antes de derived maps, mas o runtime continua autoridade para `IncludeBase()` dinamico; -- Native AOT runtime continua bloqueado neste ambiente pela ausencia do platform linker C++. - -Dividas explicitamente fora desta etapa: - -- Nested object materialization; -- Value Objects complexos; -- Multiple mapping profiles; -- Query-specific mappings; -- Custom materializer; -- Generated DbDataReader materializer. diff --git a/docs/sdd/etapa-4/README.md b/docs/sdd/etapa-4/README.md deleted file mode 100644 index 15eaa79..0000000 --- a/docs/sdd/etapa-4/README.md +++ /dev/null @@ -1,79 +0,0 @@ -# Etapa 4 - -## Objetivo - -Evoluir o `Dapper.FluentMap` com tooling de build-time e compatibilidade de publicacao, preservando o contrato runtime consolidado nas Etapas 1, 2 e 3. - -## Dependencia Das Etapas 1, 2 E 3 - -Esta etapa depende das decisoes anteriores sobre `MemberPath`, validacao runtime, heranca de mappings, naming policies, registro moderno, constructor mapping, `Validate()`, `Explain()` e provenance de mappings. - -Antes de iniciar qualquer entrega desta etapa, leia: - -- `docs/sdd/etapa-1/README.md` -- `docs/sdd/etapa-1/decisions.md` -- `docs/sdd/etapa-2/README.md` -- `docs/sdd/etapa-2/decisions.md` -- os relatorios relevantes da Etapa 2 -- `docs/sdd/etapa-3/README.md` -- `docs/sdd/etapa-3/decisions.md` -- os relatorios relevantes da Etapa 3 -- o relatorio anterior desta pasta, quando existir - -## Escopo - -Entregas: - -1. 01 - Roslyn Analyzers -2. 02 - Trimming e Native AOT -3. 03 - Source Generator - -## Compatibilidade - -- O pacote principal `Dapper.FluentMap` deve continuar em `netstandard2.0`. -- O runtime do core nao deve ganhar dependencias Roslyn. -- APIs publicas existentes devem ser preservadas. -- `Dapper.FluentMap.Dommel` nao deve receber alteracao funcional nesta etapa salvo necessidade comprovada. -- Diagnostics e IDs publicados passam a ser contrato de tooling e nao devem ser renumerados ou reutilizados. - -## Runtime Continua Autoridade - -Analyzers nao substituem `Validate()` nem as validacoes fail-fast do runtime. - -Motivos: - -- o analyzer pode nao estar instalado; -- configuracao pode ser dinamica; -- assembly scanning depende de reflection; -- construtores de maps podem executar logica arbitraria; -- consumidores podem suprimir diagnostics; -- o runtime possui informacoes indisponiveis no compilador. - -Regra principal: - -```text -Se nao for possivel provar estaticamente, nao reporte como erro. -``` - -## Resultado da Etapa 4 - -A Etapa 4 adicionou tooling build-time e validacao de publicacao sem alterar o contrato runtime principal do `Dapper.FluentMap`. - -Resumo: - -- analyzers Roslyn em `Dapper.FluentMap.Analyzers`, com diagnostics `DFM001` a `DFM005`; -- generator incremental em `Dapper.FluentMap.Generators`, com registro gerado por `AddGeneratedMappings()`; -- diagnostics novos do generator: `DFM006` para mapping candidato ignorado e `DFM007` para duplicidade geravel de entidade; -- core preservado em `netstandard2.0` e sem dependencia Roslyn; -- registro manual e `AddMap()` permanecem suportados; -- registro gerado complementa o caminho explicito para evitar assembly scanning; -- assembly scanning permanece suportado como conveniencia reflection-dependent e trimming-sensitive; -- caminho explicito e caminho gerado nao emitiram warnings FluentMap-owned nos smokes trimmed executados; -- Native AOT runtime nao foi validado no ambiente local porque faltou o platform linker C++ exigido pelo SDK; -- pacotes de analyzer/generator sao empacotados em `analyzers/dotnet/cs`, sem `lib/`. - -Relatorios: - -- `docs/sdd/etapa-4/01-roslyn-analyzers.md` -- `docs/sdd/etapa-4/02-trimming-aot.md` -- `docs/sdd/etapa-4/03-source-generator.md` diff --git a/docs/sdd/etapa-4/decisions.md b/docs/sdd/etapa-4/decisions.md deleted file mode 100644 index 33516d6..0000000 --- a/docs/sdd/etapa-4/decisions.md +++ /dev/null @@ -1,39 +0,0 @@ -# Decisoes Da Etapa 4 - -Registre aqui apenas decisoes que afetem entregas posteriores. - -## Roslyn Analyzers - -- Analyzers sao entregues em projeto e pacote isolado `Dapper.FluentMap.Analyzers`, sem referencia do core para Roslyn. -- A primeira versao dos diagnostics usa o prefixo `DFM` e IDs `DFM001` a `DFM005`. -- Todos os diagnostics iniciais sao `Error`, mas somente para situacoes provadas estaticamente com alto grau de confianca. -- Duplicidade de `MemberPath` e conflito de coluna sao analisados apenas para chamadas diretas de `Map(...).ToColumn(...)` em statements diretos do construtor do `EntityMap`. -- O analyzer nao executa codigo de usuario, nao instancia maps, nao faz reflection runtime e nao acessa banco. -- Regras dependentes de fluxo de execucao, chamadas auxiliares, scanning de assembly, construtores de maps, ordem real de registro ou estado global permanecem sob autoridade de `Validate()` e das validacoes runtime. -- Nao foi criado Code Fix Provider porque nenhuma correcao inicial e inequivoca sem risco de alterar semantica. - -## Trimming E Native AOT - -- A Entrega 02 deve considerar que o analyzer ja identifica alguns usos estaticamente invalidos de `AddMap()`, mas isso nao remove a divida de reflection documentada na Etapa 3. -- Registro explicito por `AddMap()` e o caminho recomendado para consumidores com IL trimming e Native AOT; ele nao emite warnings FluentMap-owned no smoke trimmed depois desta entrega. -- Assembly scanning permanece reflection-dependent por design e foi marcado com `RequiresUnreferencedCode` em `AddMapsFromAssembly(...)`, `AddMapsFromAssemblyContaining()`, `ForEntitiesInAssembly(...)`, `ForEntitiesInCurrentAssembly(...)` e `ApplyMapsFromAssemblies(...)`. -- O registry nao cria mais type maps por `Activator.CreateInstance(typeof(FluentMapTypeMap<>).MakeGenericType(type))`; um type map interno nao generico remove esse ponto de reflection dinamica sem remover a classe publica `FluentMapTypeMap`. -- `DynamicallyAccessedMembers` foi aplicado somente a fluxos verificaveis: interfaces do tipo de map em `AddMap()`, propriedades publicas em `ForEntity()`, e propriedades/construtores publicos em `Explain()`. -- Warnings restantes no smoke trimmed explicito pertencem ao Dapper (`DefaultTypeMap`, `CustomPropertyTypeMap`, `DapperRow` e helpers internos); nao devem ser corrigidos copiando ou alterando codigo do Dapper dentro do FluentMap. -- Native AOT runtime nao foi validado nesta entrega porque o ambiente Windows nao possui o platform linker C++ exigido pelo SDK. -- Metadata candidata para Source Generator na Entrega 03: entidade alvo de `AddMap()`, caminhos `Map(...)`, colunas `ToColumn(...)`, `Ignore()`, `IncludeBase()`, naming policies estaticas e instalacao de type maps sem discovery por assembly. -- O Source Generator nao deve tentar tornar `AddMapsFromAssembly(...)` AOT-safe; ele deve oferecer um caminho gerado/explicito que substitua scanning quando o consumidor desejar publicacao trimmed/AOT. - -## Source Generator - -- A Entrega 03 pode reutilizar a leitura estatica de `Map(...)`, `ToColumn(...)`, `IncludeBase(...)` e `AddMap()`, mas nao deve depender de diagnostics como unica fonte de verdade. -- O Source Generator foi entregue em projeto separado `Dapper.FluentMap.Generators`, empacotado em `analyzers/dotnet/cs`, sem referencia do core para Roslyn. -- A descoberta inicial e limitada a classes de mapping declaradas na compilacao atual; assemblies referenciados nao sao percorridos automaticamente. -- A API gerada e `Dapper.FluentMap.DapperFluentMapGeneratedRegistration.AddGeneratedMappings(...)`, exposta como extension method interno no assembly consumidor. -- O codigo gerado chama somente `FluentMapConfiguration.AddMap()`, preservando o `MappingRegistry`, validacoes runtime, inheritance, conventions, naming policies e constructor mapping existentes. -- O generator e incremental, baseado em symbols, nao executa codigo do consumidor e nao instancia mappings durante a geracao. -- O caminho gerado nao usa assembly scanning, `GetTypes`, `GetExportedTypes` ou `Activator.CreateInstance(Type)`. -- `DFM005` foi reutilizado para tipos que nao satisfazem o contrato de exatamente uma interface fechada `IEntityMap` para entidade class. -- Novos diagnostics de generator: `DFM006` para mapping candidato ignorado no registro gerado e `DFM007` para duplicidade de entity maps geraveis na compilacao atual. -- Abstract maps, open generic maps, maps inacessiveis e maps sem construtor publico sem parametros sao reportados por `DFM006` e ignorados, evitando que o caminho gerado produza chamadas que nao compilam. -- O generator nao declara suporte a nested object materialization, Value Objects complexos, multiple mapping profiles, query-specific mappings, custom materializer ou generated `DbDataReader` materializer. diff --git a/docs/sdd/etapa-4/status.md b/docs/sdd/etapa-4/status.md deleted file mode 100644 index fcf645e..0000000 --- a/docs/sdd/etapa-4/status.md +++ /dev/null @@ -1,5 +0,0 @@ -| Entrega | Status | Commit | -|---|---|---| -| 01 - Roslyn Analyzers | Concluido | 9075f61 | -| 02 - Trimming e Native AOT | Concluido | d559d65 | -| 03 - Source Generator | Concluido | este commit | diff --git a/docs/sdd/etapa-5/01-nested-materialization-spike.md b/docs/sdd/etapa-5/01-nested-materialization-spike.md deleted file mode 100644 index 402ffb9..0000000 --- a/docs/sdd/etapa-5/01-nested-materialization-spike.md +++ /dev/null @@ -1,494 +0,0 @@ -# 01 - Spike Nested/Value-Object Materialization - -## Specification - -Existe demanda historica para mappings como: - -```csharp -Map(x => x.Address.City).ToColumn("city"); -Map(x => x.Document.Number).ToColumn("cpf"); -``` - -A Etapa 2 introduziu `MemberPath`, portanto o core ja consegue representar: - -```text -Address.City -Document.Number -``` - -O problema desta entrega foi verificar se representar o caminho e suficiente para o Dapper materializar o grafo completo, ou se o FluentMap precisa controlar parte da materializacao. - -Casos obrigatorios avaliados: - -- nested mutable object: `Customer.Address.City`; -- dois paths com terminal igual: `Rank.Level` e `Seniority.Level`; -- Value Object imutavel: `Cpf.Number`; -- nested record: `Customer(int Id, Address Address)`. - -## Discovery - -Arquivos analisados no FluentMap: - -- `src/Dapper.FluentMap/Mapping/MemberPath.cs` -- `src/Dapper.FluentMap/Mapping/EntityMap.cs` -- `src/Dapper.FluentMap/Mapping/PropertyMap.cs` -- `src/Dapper.FluentMap/Mapping/PropertyMapIdentity.cs` -- `src/Dapper.FluentMap/MappingRegistry.cs` -- `src/Dapper.FluentMap/TypeMaps/FluentMapTypeMap.cs` -- `src/Dapper.FluentMap/TypeMaps/FluentConstructorTypeMap.cs` -- `src/Dapper.FluentMap/TypeMaps/ConstructorParameterMap.cs` -- `src/Dapper.FluentMap/TypeMaps/MultiTypeMap.cs` -- `src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs` -- testes de integracao e constructor mapping do core. - -Fontes do Dapper 2.1.79 analisadas: - -- pacote local `Dapper` 2.1.79 referenciado pelo projeto; -- tag oficial `2.1.79` do repositorio `DapperLib/Dapper`, commit `72a54c475f75e18cb93cba0809d00a5e6e49efd9`; -- `SqlMapper.ITypeMap.cs`; -- `SqlMapper.IMemberMap.cs`; -- `DefaultTypeMap.cs`; -- `CustomPropertyTypeMap.cs`; -- `SqlMapper.cs`, especialmente `GenerateDeserializerFromMap`. - -Links de referencia primaria: - -- `ITypeMap`: https://github.com/DapperLib/Dapper/blob/72a54c475f75e18cb93cba0809d00a5e6e49efd9/Dapper/SqlMapper.ITypeMap.cs -- `IMemberMap`: https://github.com/DapperLib/Dapper/blob/72a54c475f75e18cb93cba0809d00a5e6e49efd9/Dapper/SqlMapper.IMemberMap.cs -- `DefaultTypeMap`: https://github.com/DapperLib/Dapper/blob/72a54c475f75e18cb93cba0809d00a5e6e49efd9/Dapper/DefaultTypeMap.cs -- `CustomPropertyTypeMap`: https://github.com/DapperLib/Dapper/blob/72a54c475f75e18cb93cba0809d00a5e6e49efd9/Dapper/CustomPropertyTypeMap.cs -- materializer IL: https://github.com/DapperLib/Dapper/blob/72a54c475f75e18cb93cba0809d00a5e6e49efd9/Dapper/SqlMapper.cs - -### O que ITypeMap consegue fazer - -`SqlMapper.ITypeMap` consegue: - -- escolher construtor com `FindConstructor`; -- forcar construtor explicito com `FindExplicitConstructor`; -- mapear coluna para parametro de construtor com `GetConstructorParameter`; -- mapear coluna para um membro simples com `GetMember`. - -Isso e suficiente para: - -- propriedades simples; -- fields simples; -- parametros de construtor simples; -- aliases de coluna; -- constructor mapping de records/classes imutaveis quando os parametros correspondem a propriedades simples. - -### O que ITypeMap nao consegue fazer - -`ITypeMap` nao recebe nem retorna: - -- um `MemberPath`; -- uma callback de atribuicao; -- uma factory de objetos intermediarios; -- uma estrategia de nullability; -- um plano de construcao de grafo; -- um contexto de objeto raiz + caminho. - -`IMemberMap` possui apenas: - -```text -ColumnName -MemberType -PropertyInfo -FieldInfo -ParameterInfo -``` - -Nao ha contrato publico para "atribua esta coluna a Address.City criando Address se necessario". - -### Onde o setter e emitido - -No `GenerateDeserializerFromMap`, o Dapper: - -1. obtem o `ITypeMap` do tipo raiz; -2. resolve cada coluna para `IMemberMap`; -3. quando nao usa construtor especializado, emite IL para setter de propriedade ou field; -4. para propriedade, chama `DefaultTypeMap.GetPropertySetterOrThrow(item.Property, type)`; -5. para field, emite `Stfld`. - -O `type` usado e o tipo raiz que esta sendo materializado. Quando o `PropertyInfo` pertence ao tipo aninhado, o Dapper nao conhece a cadeia intermediaria. O teste de caracterizacao mostrou que devolver o leaf `Address.City` pode fazer o valor escalar ser escrito no slot errado do objeto raiz, em vez de criar `Address`. - -### Custom IMemberMap - -Um `IMemberMap` customizado nao resolve nested assignment porque ele nao contem operacao de atribuicao. Mesmo com um `ITypeMap` puro retornando o `PropertyInfo` do leaf, o Dapper continua emitindo setter simples para o tipo raiz. - -### Constructor mapping - -O `FluentConstructorTypeMap` existente filtra `MemberPath.IsNested`, por decisao da Etapa 3. Isso esta correto: parametros de construtor do tipo raiz nao sao `MemberPath`. - -Nested record falha porque o Dapper procura um construtor de `Customer` cujos parametros correspondam as colunas. A coluna `city` nao corresponde ao parametro `Address address`, nem fornece como criar `Address`. - -### TypeHandlers - -TypeHandler resolve Value Object escalar quando o destino do Dapper e o Value Object inteiro: - -```csharp -Map(x => x.Cpf).ToColumn("cpf"); -``` - -Com um `SqlMapper.TypeHandler`, o Dapper converte `varchar -> Cpf` e atribui `Cpf`. - -TypeHandler nao resolve: - -```csharp -Map(x => x.Cpf.Number).ToColumn("cpf"); -``` - -Nesse caso o destino exposto ao Dapper e o membro terminal `Number`, cujo tipo e `string`. O handler de `Cpf` nao participa, e a cadeia `Customer.Cpf` nao e criada. - -### Multi-mapping - -Multi-mapping do Dapper (`Query`) materializa varios objetos em segmentos de coluna e delega composicao a uma callback do consumidor. Ele pode ser usado pelo usuario para compor `Customer` + `Address`, mas nao e uma boa base interna generica para nested mapping arbitrario porque: - -- exige conhecimento de `splitOn`; -- segmenta por tipos, nao por `MemberPath`; -- nao resolve multiplos paths para o mesmo tipo ou mesmo terminal; -- nao cobre bem Value Objects escalares; -- mudaria demais a API para consultas simples. - -### Source generation - -O generator da Etapa 4 gera apenas registro: - -```csharp -configuration.AddMap(); -``` - -Ele nao le `DbDataReader`, nao gera assignment e nao materializa objetos. Porem, a infraestrutura pode ser evoluida no futuro para gerar materializers especializados, o que e interessante para: - -- performance; -- Native AOT; -- trimming; -- records; -- grafos imutaveis. - -## Experimentos - -Arquivo adicionado: - -- `test/Dapper.FluentMap.Tests/NestedMaterializationSpikeTests.cs` - -Testes de caracterizacao: - -| Teste | Evidencia | -|---|---| -| `NestedMutablePathShouldWriteLeafValueIntoRootSlotInsteadOfMaterializingGraph` | `Map(x => x.Address.City)` nao cria `Address`; o valor do leaf aparece no slot do root, evidenciando que Dapper recebeu apenas o terminal. | -| `NestedPathsWithSameTerminalShouldBeConfiguredButDapperStillReceivesOnlyTerminalMembers` | `Rank.Level` e `Seniority.Level` coexistem em `Explain`, mas a materializacao por Dapper nao preserva os caminhos. | -| `TypeHandlerShouldMaterializeScalarValueObjectProperty` | `TypeHandler` funciona quando o destino e `Customer.Cpf`. | -| `TypeHandlerShouldNotMaterializeNestedValueObjectPath` | `TypeHandler` nao participa quando o mapping e `Customer.Cpf.Number`. | -| `NestedRecordShouldNotMaterializeThroughConstructorMapping` | Nested record falha por ausencia de construtor correspondente a colunas planas. | -| `PureITypeMapReturningNestedLeafPropertyShouldWriteLeafValueIntoRootSlot` | Mesmo sem FluentMap, `ITypeMap` puro retornando leaf `PropertyInfo` nao representa nested assignment. | - -Validacao localizada: - -```text -dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~NestedMaterializationSpikeTests" -``` - -Resultado: - -```text -6 testes aprovados -``` - -## Alternativas - -### Alternativa A - ITypeMap puro - -Vantagens: - -- maxima compatibilidade com `Dapper.Query`; -- pouca API nova; -- baixo custo inicial. - -Limites comprovados: - -- `IMemberMap` so possui `PropertyInfo`, `FieldInfo` ou `ParameterInfo`; -- nao ha callback de assignment; -- nao ha criacao de intermediarios; -- devolver o `PropertyInfo` terminal pode escrever no slot errado do objeto raiz; -- nao suporta nested records ou Value Objects aninhados. - -Conclusao: - -```text -Rejeitada como arquitetura principal. -``` - -### Alternativa B - Dapper TypeHandler - -Vantagens: - -- usa mecanismo publico do Dapper; -- bom para `varchar -> Cpf`, `int -> Money`, etc.; -- baixo custo; -- compoe com constructor mapping simples. - -Limites: - -- funciona por tipo de destino, nao por caminho; -- nao cria `Customer.Cpf`; -- nao resolve `Customer.Cpf.Number`; -- nao constroi grafos imutaveis. - -Conclusao: - -```text -Aceita como estrategia complementar para Value Objects escalares. -``` - -### Alternativa C - Wrapper de Query - -Exemplo conceitual: - -```csharp -connection.QueryMapped(sql, param); -``` - -Vantagens: - -- caminho opt-in, preservando `Dapper.Query`; -- permite controlar `DbDataReader`, nullability, criacao de intermediarios e assignments por `MemberPath`; -- permite rejeitar cenarios nao suportados com diagnostico claro; -- nao exige fork do Dapper. - -Custos: - -- nova API paralela; -- precisa implementar plano de materializacao; -- precisa definir conversoes, TypeHandlers, cache e diagnostico; -- pode duplicar parte pequena da materializacao simples. - -Conclusao: - -```text -Direcao principal para Entrega 2. -``` - -### Alternativa D - Source-generated materializer - -Vantagens: - -- melhor potencial de performance; -- melhor caminho para trimming e Native AOT; -- pode gerar codigo direto para records, construtores e Value Objects; -- reduz reflection no hot path. - -Custos: - -- complexidade alta; -- exige projeto generator mais ambicioso; -- nao cobre configuracao dinamica; -- aumenta custo de manutencao. - -Conclusao: - -```text -Estrategia futura/complementar, especialmente para Entrega 3 e AOT. -``` - -### Alternativa E - Post-materialization/intermediario - -Modelo: - -```text -DbDataReader ou DapperRow - -> valores por coluna - -> plano FluentMap - -> objeto final -``` - -Vantagens: - -- evita depender de internals do Dapper; -- permite usar Dapper para executar comando e obter valores; -- controla nested paths de forma deterministica; -- pode cachear planos por tipo e shape de colunas. - -Custos: - -- alocacao de representacao intermediaria se usar `DapperRow`; -- conversoes precisam ser definidas; -- objetos imutaveis exigem fase de construcao distinta. - -Conclusao: - -```text -Provavel implementacao inicial do wrapper de Query. -``` - -## Tabela Comparativa - -| Criterio | A - ITypeMap puro | B - TypeHandler | C - Query wrapper | D - Source-generated materializer | E - Post-materialization | -|---|---|---|---|---|---| -| Compatibilidade com API atual | Alta | Alta | Media, API nova opt-in | Media, exige generator | Media, API nova opt-in | -| Complexidade | Baixa | Baixa | Media | Alta | Media | -| Performance | Alta quando simples, invalida para nested | Alta | Media | Alta | Media | -| AOT/trimming | Limitado pelo Dapper | Igual Dapper | Reflection-sensitive se runtime | Melhor potencial | Reflection-sensitive se runtime | -| Records | Simples apenas | Escalar apenas | Possivel | Melhor opcao | Possivel com plano | -| Value Objects | Nao | Escalares | Possivel | Possivel | Possivel | -| Nested mutable objects | Nao seguro | Nao | Sim | Sim | Sim | -| Nested immutable objects | Nao | Nao | Possivel com construtores | Sim | Possivel com construtores | -| Debuggability | Baixa para nested | Alta | Alta se diagnostico proprio | Media | Alta | -| Manutenibilidade | Ruim para nested | Boa | Boa se escopo estreito | Mais cara | Boa se escopo estreito | -| Dependencia de internals do Dapper | Baixa, mas insuficiente | Baixa | Baixa | Baixa/media | Baixa | - -## Decision - -Direcao principal: - -```text -Nested materialization deve ser implementada por caminho opt-in controlado pelo FluentMap, provavelmente `QueryMapped`, usando um plano de materializacao baseado em MemberPath. -``` - -Estrategia complementar: - -```text -Value Objects escalares devem continuar usando Dapper TypeHandlers quando o destino mapeado e o Value Object inteiro. -``` - -Estrategia futura: - -```text -Source-generated materializers devem ser avaliados para nested immutable graphs, records e cenarios trimmed/AOT, mas nao sao pre-requisito para iniciar nested mutable objects. -``` - -### API publica provavel - -Ainda nao definitiva: - -```csharp -connection.QueryMapped(sql, param); -connection.QueryMappedSingle(sql, param); -``` - -Regras provaveis: - -- API opt-in em namespace `Dapper.FluentMap`; -- nao substituir `Dapper.Query`; -- usar mappings registrados no `MappingRegistry`; -- aceitar somente cenarios validados inicialmente; -- falhar com diagnostico claro quando path intermediario nao puder ser criado. - -### O que continua usando Dapper normal - -- mappings simples; -- conventions e naming policies simples; -- constructor mapping simples; -- records posicionais simples; -- fallback default do Dapper; -- TypeHandlers escalares. - -### Quando FluentMap precisa controlar materializacao - -FluentMap precisa controlar quando houver: - -- `MemberPath.IsNested`; -- criacao de objetos intermediarios; -- nested Value Object; -- nested record; -- grafo imutavel; -- necessidade de preservar dois paths com mesmo terminal; -- nullability ou ausencia de intermediario. - -## Impacto Em AOT E Performance - -Runtime wrapper/reflection: - -- menor custo de implementacao; -- bom para provar semantics da Entrega 2; -- precisa cachear planos por tipo e shape de colunas; -- sera trimming-sensitive se depender de reflection ampla. - -Source-generated materializer: - -- melhor caminho para AOT; -- pode remover reflection do hot path; -- deve reaproveitar metadata estatica do generator da Etapa 4; -- aumenta complexidade e deve ser entregue separadamente. - -TypeHandler: - -- performance boa e integrada ao Dapper; -- AOT depende do proprio handler e do Dapper; -- nao cobre nested path. - -## Riscos - -- Escrever nested paths no type map atual do Dapper pode produzir atribuicoes incorretas; a Entrega 2 deve neutralizar esse caminho. -- Criar `QueryMapped` amplia superficie publica e precisa de nomes, overloads e comportamento compativeis. -- Conversoes devem respeitar TypeHandlers sem copiar internals do Dapper. -- Nullability de intermediarios precisa de regra explicita: criar, preservar null ou falhar. -- Grafos imutaveis exigem construtor/factory e nao devem ser misturados com a primeira entrega de mutable nested objects sem testes suficientes. -- Cache de planos deve incluir tipo, colunas, ordem e configuracao que altera resultado. - -## Instrucoes Para Entrega 2 - -- Comecar por nested mutable object com construtor sem parametros e propriedades settable. -- Criar API opt-in em vez de prometer suporte via `Dapper.Query`. -- Rejeitar paths cuja cadeia intermediaria nao tenha setter ou construtor suportado. -- Criar objetos intermediarios apenas quando a coluna do leaf tiver valor materializavel. -- Preservar dois paths com mesmo terminal (`Rank.Level` e `Seniority.Level`) usando `MemberPath` completo. -- Adicionar diagnostico claro para caminhos nao suportados. -- Alterar o type map atual para nao devolver leaf `PropertyInfo` aninhado ao Dapper como se fosse propriedade simples. - -## Instrucoes Para Entrega 3 - -- Suportar Value Objects escalares primeiro via documentacao/testes de TypeHandler. -- Para Value Objects imutaveis aninhados, definir como construir o objeto: TypeHandler, construtor unico, factory explicita ou materializer gerado. -- Nao tratar `Cpf.Number` como equivalente automatico a `Cpf`. -- Records aninhados devem passar por plano de construtor, nao por setter terminal. -- Avaliar source generation quando o runtime reflection-based ficar complexo ou produzir warnings AOT relevantes. - -## Delivery - -Arquivos adicionados: - -- `test/Dapper.FluentMap.Tests/NestedMaterializationSpikeTests.cs` -- `docs/sdd/etapa-5/README.md` -- `docs/sdd/etapa-5/status.md` -- `docs/sdd/etapa-5/decisions.md` -- `docs/sdd/etapa-5/01-nested-materialization-spike.md` - -Nao foram alterados: - -- codigo de producao do core; -- Dommel; -- TargetFrameworks; -- metadados de pacote; -- source generator. - -## Validation - -Validacao localizada executada durante o spike: - -```text -dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~NestedMaterializationSpikeTests" -``` - -Resultado: - -```text -Sucesso, 6 testes aprovados. -``` - -Validacao final deve executar: - -```text -dotnet restore -dotnet build -dotnet test -dotnet build --configuration Release -dotnet test --configuration Release -``` - -## Semantic Commit - -Mensagem planejada: - -```text -test: characterize nested mapping constraints -``` diff --git a/docs/sdd/etapa-5/02-nested-object-materialization.md b/docs/sdd/etapa-5/02-nested-object-materialization.md deleted file mode 100644 index dd96abf..0000000 --- a/docs/sdd/etapa-5/02-nested-object-materialization.md +++ /dev/null @@ -1,271 +0,0 @@ -# 02 - Nested Object Materialization - -## Specification - -Esta entrega implementa suporte real e opt-in para materializar objetos aninhados mutaveis a partir de mappings baseados em `MemberPath`. - -Exemplo suportado: - -```csharp -Map(x => x.Address.City).ToColumn("city"); - -var customer = connection.QueryMappedSingle( - "SELECT 'Sao Paulo' AS city;"); -``` - -Resultado: - -```text -Customer -└── Address - └── City = "Sao Paulo" -``` - -O caminho regular `Dapper.Query` permanece preservado para mappings simples, constructor mapping simples, conventions, naming policies e fallback do Dapper. Nested materialization nao e prometida implicitamente por `Dapper.Query`. - -## Discovery - -Arquivos analisados: - -- `docs/sdd/etapa-5/01-nested-materialization-spike.md` -- `docs/sdd/etapa-2/01-member-path.md` -- `docs/sdd/etapa-3/02-constructor-immutable-mapping.md` -- `docs/sdd/etapa-4/02-trimming-aot.md` -- `docs/sdd/etapa-4/03-source-generator.md` -- `src/Dapper.FluentMap/MappingRegistry.cs` -- `src/Dapper.FluentMap/MappingConfigurationValidator.cs` -- `src/Dapper.FluentMap/Mapping/MemberPath.cs` -- `src/Dapper.FluentMap/TypeMaps/FluentConstructorTypeMap.cs` -- `src/Dapper.FluentMap/TypeMaps/FluentMapTypeMap.cs` -- `src/Dapper.FluentMap/Diagnostics/*` -- testes de integracao com SQLite. - -Confirmacao obrigatoria: - -```text -01 - Spike nested/value-object -> Concluido -``` - -## Decision - -A arquitetura escolhida no spike foi mantida: - -- nested materialization usa API opt-in controlada pelo FluentMap; -- `Dapper.Query` nao tenta materializar grafos aninhados; -- `MemberPath` e a identidade do caminho; -- o plano de materializacao e criado a partir do shape de colunas; -- o plano usa a precedencia efetiva do `MappingRegistry`: explicito, herdado, convention/naming policy e fallback do Dapper para membros raiz. - -API adicionada: - -```csharp -connection.QueryMapped(sql, param, transaction, commandTimeout, commandType); -connection.QueryMappedSingle(sql, param, transaction, commandTimeout, commandType); -``` - -## Supported Scope - -Suportado nesta entrega: - -- objetos aninhados mutaveis; -- um nivel, por exemplo `Customer.Address.City`; -- multiplos niveis, por exemplo `Customer.Address.Country.Name`; -- paths com mesmo terminal, por exemplo `Rank.Level` e `Seniority.Level`; -- mappings explicitos e herdados; -- naming policies e conventions para propriedades raiz; -- fallback tradicional de POCO raiz settable; -- multiplas linhas; -- `Explain` indicando `Materialization = Nested`. - -Fora do escopo: - -- Value Objects imutaveis aninhados; -- nested records; -- construcao de grafos imutaveis por construtor; -- collections no meio do path; -- indexers, static members e paths readonly; -- materializer gerado. - -## Null Semantics - -As regras sao por subarvore nested: - -- se todos os valores correspondentes a uma subarvore nested forem `NULL`, o objeto intermediario dessa subarvore fica `null`; -- se o root ou um intermediario ja criou esse objeto por construtor/inicializador, o materializer limpa a propriedade para `null` quando ela e settable; -- se pelo menos um valor da subarvore nested nao for `NULL`, o objeto intermediario e criado quando estiver `null`; -- valores leaf `NULL` dentro de uma subarvore criada sao atribuidos como `null` para reference/nullable types ou como default para value types nao anulaveis; -- nulabilidade C# por NRT nao e interpretada nesta entrega, porque o core permanece sem nullable annotations ponta a ponta. - -Assim: - -```text -city = NULL -``` - -mantem `Address = null` quando `Address.City` e o unico valor nested. - -E: - -```text -city = NULL, postal_code = '01000' -``` - -cria `Address`, define `City = null` e `PostalCode = '01000'`. - -## Construction Semantics - -Para nested materialization runtime: - -- o tipo raiz consultado por `QueryMapped*` deve ter construtor publico sem parametros; -- cada propriedade intermediaria deve ter getter publico, setter publico e tipo com construtor publico sem parametros; -- cada leaf nested deve ser settable; -- objetos intermediarios existentes sao reutilizados quando a subarvore possui dados; -- falhas de construcao sao reportadas como `FluentMapConfigurationException` durante a criacao do plano `QueryMapped*`, antes da materializacao das linhas. - -Nao ha reflection para construtores privados nesta entrega. - -## Validation - -A validacao de configuracao rejeita antecipadamente: - -- membro intermediario sem getter/setter publico; -- leaf nested readonly; -- collection no meio do path; -- indexer; -- static member; -- prefix conflict, como mapear `Address` e `Address.City` ao mesmo tempo. - -A validacao do plano `QueryMapped*` rejeita, antes de ler linhas, tipo raiz ou intermediario sem construtor publico sem parametros. Essa checagem fica no caminho opt-in anotado para trimming/dynamic-code para preservar o caminho de registro explicito sem warnings FluentMap-owned. - -## Performance - -O caminho opt-in cria e cacheia um plano por: - -```text -tipo raiz + lista ordinal de colunas -``` - -O plano pre-computa: - -- resolucao de mapping por coluna; -- arvore de `MemberPath`; -- delegates de getter/setter; -- factories de construtores sem parametros; -- indices de colunas por subarvore para decidir `NULL` total/parcial. - -Por linha, o materializer executa leitura do valor, conversao leve e chamada dos delegates cacheados. Nao ha busca ampla de reflection por coluna por linha. - -## AOT And Trimming - -`QueryMapped*` e um caminho runtime/reflection-based e compila delegates em tempo de execucao. Por isso, as APIs foram anotadas com: - -- `RequiresUnreferencedCode`; -- `RequiresDynamicCode`. - -O source generator da Etapa 4 continua limitado a registro de mappings. Um materializer gerado permanece a estrategia futura preferencial para consumidores trimmed/Native AOT. - -## Diagnostics - -`MemberMappingExplanation` recebeu a propriedade: - -```csharp -MappingMaterialization Materialization -``` - -Valores: - -- `Dapper` para mappings raiz e fallback tradicional; -- `Nested` para paths aninhados materializados pelo wrapper opt-in. - -Consumidores existentes nao quebram porque a API de diagnostico foi ampliada sem remover membros existentes. - -## Delivery - -Arquivos adicionados: - -- `src/Dapper.FluentMap/Diagnostics/MappingMaterialization.cs` -- `src/Dapper.FluentMap/Materialization/MaterializationPlanCacheKey.cs` -- `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs` -- `src/Dapper.FluentMap/QueryMappedExtensions.cs` -- `test/Dapper.FluentMap.Tests/NestedObjectMaterializationTests.cs` -- `docs/sdd/etapa-5/02-nested-object-materialization.md` - -Arquivos alterados: - -- `README.md` -- `src/Dapper.FluentMap/Compatibility/CodeAnalysisAttributes.cs` -- `src/Dapper.FluentMap/Diagnostics/MemberMappingExplanation.cs` -- `src/Dapper.FluentMap/MappingConfigurationValidator.cs` -- `src/Dapper.FluentMap/MappingRegistry.cs` -- `test/Dapper.FluentMap.Tests/NestedMaterializationSpikeTests.cs` -- `docs/sdd/etapa-5/decisions.md` -- `docs/sdd/etapa-5/status.md` - -## Validation - -Validacao localizada executada durante a implementacao: - -```text -dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~NestedObjectMaterializationTests" -``` - -Resultado: - -```text -16 testes aprovados -``` - -Validacao relacionada: - -```text -dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~NestedMaterializationSpikeTests|FullyQualifiedName~NestedObjectMaterializationTests|FullyQualifiedName~DiagnosticsApiTests|FullyQualifiedName~ConstructorMappingTests" -``` - -Resultado: - -```text -43 testes aprovados -``` - -Validacao final completa deve registrar: - -```text -dotnet restore -dotnet build -dotnet test -dotnet build --configuration Release -dotnet test --configuration Release -``` - -Resultado final: - -- `dotnet restore`: sucesso; -- `dotnet build`: sucesso, 0 warnings, 0 erros; -- `dotnet test`: sucesso, 150 testes do core, 7 Dommel, 7 analyzer, 12 generator e 1 generated-registration integration; -- `dotnet build --configuration Release`: sucesso, 0 warnings, 0 erros; -- `dotnet test --configuration Release`: sucesso com os mesmos 177 testes. - -Smokes AOT/trimming: - -- `dotnet run --project .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release`: `explicit:ok`; -- `dotnet run --project .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:DefineConstants=AOT_SMOKE_SCANNING`: `scanning:ok`; -- `dotnet run --project .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:DefineConstants=AOT_SMOKE_GENERATED`: `generated:ok`; -- publish trimmed explicit: sucesso, runtime `explicit:ok`, sem warnings FluentMap-owned; warnings restantes pertencem ao Dapper; -- publish trimmed generated: sucesso, runtime `generated:ok`, sem warnings FluentMap-owned; warnings restantes pertencem ao Dapper; -- publish Native AOT explicit: falhou no ambiente com `Platform linker not found`; runtime Native AOT nao foi validado. - -## Limitations For Delivery 3 - -- Value Objects imutaveis aninhados devem definir construcao por TypeHandler, construtor, factory ou materializer gerado. -- Nested records exigem plano de construtor em vez de setters. -- NRT metadata nao foi usada para diferenciar intermediarios nullable/non-nullable. -- O caminho runtime nao e a estrategia ideal para Native AOT. -- Conversoes cobrem escalares comuns e fallback settable; conversoes complexas devem ser tratadas por entrega dedicada. - -## Semantic Commit - -Mensagem: - -```text -feat: support nested object mappings -``` diff --git a/docs/sdd/etapa-5/03-value-objects.md b/docs/sdd/etapa-5/03-value-objects.md deleted file mode 100644 index b88fe8d..0000000 --- a/docs/sdd/etapa-5/03-value-objects.md +++ /dev/null @@ -1,328 +0,0 @@ -# 03 - Value Objects Imutaveis - -## Specification - -Esta entrega adiciona suporte opt-in para materializar Value Objects imutaveis e nested immutable objects pelo caminho `QueryMapped` / `QueryMappedSingle`, sem exigir setters publicos e sem contornar invariantes do dominio. - -Exemplo suportado: - -```csharp -Map(customer => customer.Id).ToColumn("id"); -Map(customer => customer.Cpf.Number).ToColumn("cpf"); - -var customer = connection.QueryMappedSingle( - "SELECT 1 AS id, '12345678909' AS cpf;"); -``` - -O modelo pode expor apenas getters e construtores publicos: - -```csharp -public sealed class Customer -{ - public Customer(int id, Cpf cpf) - { - Id = id; - Cpf = cpf; - } - - public int Id { get; } - public Cpf Cpf { get; } -} - -public sealed class Cpf -{ - public Cpf(string number) - { - Number = number; - } - - public string Number { get; } -} -``` - -## Discovery - -Arquivos analisados: - -- `docs/sdd/etapa-5/README.md` -- `docs/sdd/etapa-5/status.md` -- `docs/sdd/etapa-5/decisions.md` -- `docs/sdd/etapa-5/01-nested-materialization-spike.md` -- `docs/sdd/etapa-5/02-nested-object-materialization.md` -- `docs/sdd/etapa-3/02-constructor-immutable-mapping.md` -- `docs/sdd/etapa-4/02-trimming-aot.md` -- `docs/sdd/etapa-4/03-source-generator.md` -- `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs` -- `src/Dapper.FluentMap/MappingConfigurationValidator.cs` -- `src/Dapper.FluentMap/MappingRegistry.cs` -- `src/Dapper.FluentMap/Diagnostics/*` -- `test/Dapper.FluentMap.Tests/NestedObjectMaterializationTests.cs` -- `test/Dapper.FluentMap.Tests/NestedMaterializationSpikeTests.cs` -- `test/Dapper.FluentMap.AotSmoke/Program.cs` - -Confirmacao obrigatoria: - -```text -01 - Spike nested/value-object -> Concluido -02 - Nested object materialization -> Concluido -``` - -## Decision - -### TypeHandler Boundary - -Value Objects escalares mapeados como propriedade inteira continuam sendo responsabilidade do Dapper TypeHandler: - -```csharp -Map(customer => customer.Cpf).ToColumn("cpf"); -SqlMapper.AddTypeHandler(new CpfTypeHandler()); -``` - -Esse caminho permanece ideal para conversoes simples como `string -> Cpf`, inclusive com `Dapper.Query`. - -Para paths aninhados, o TypeHandler nao e suficiente: - -```csharp -Map(customer => customer.Cpf.Number).ToColumn("cpf"); -``` - -Nesse caso o destino conceitual e o grafo `Customer.Cpf`, nao apenas o membro terminal `Number`. O suporte foi implementado no materializer opt-in do FluentMap. - -### Constructor Strategy - -O plano runtime agora constroi uma arvore por `MemberPath` e seleciona construtores publicos por nome de parametro: - -- parametros sao associados a propriedades mapeadas por nome, case-insensitive; -- `Cpf(string number)` recebe `Cpf.Number`; -- `Money(decimal amount, string currency)` recebe `Money.Amount` e `Money.Currency`; -- `Customer(int id, Cpf cpf)` recebe o valor simples `Id` e o objeto `Cpf` ja materializado; -- objetos mutaveis com construtor publico sem parametros e setters continuam usando o caminho anterior; -- construtores sao pre-computados no plano por tipo + shape de colunas; -- o hot path por linha usa delegates compilados e bindings ja resolvidos. - -Factory methods como `Cpf.Create(...)` foram avaliados, mas nao implementados. Uma DSL de factories exigiria uma API publica explicita, forte em tipos e com regras de ambiguidade proprias. Esta entrega manteve somente construtores publicos. - -## Supported Scope - -Suportado nesta entrega: - -- Value Object de um valor, como `Cpf(string number)`; -- record de um valor, como `record Email(string Value)`; -- Value Object com varios componentes, como `Money(decimal amount, string currency)`; -- nested immutable object, como `Customer(Address address)` e `Address(string city)`; -- Value Object nullable por semantica runtime de referencia: subarvore toda `NULL` resulta em `null`; -- dois Value Objects no mesmo tipo; -- paths com mesmo terminal em objetos distintos; -- mappings herdados por `IncludeBase()`; -- naming policy para propriedades raiz combinada com nested value object explicito; -- root immutable constructor mapping no caminho `QueryMapped*`; -- TypeHandler quando o destino mapeado e o Value Object inteiro. - -Fora do escopo: - -- factory methods; -- private constructor; -- private setter via reflection; -- field/backing field injection; -- `FormatterServices`; -- colecoes no meio do path; -- nullability NRT como contrato runtime; -- generated DbDataReader materializer. - -## Validation - -Configuracoes impossiveis sao rejeitadas com `FluentMapConfigurationException`: - -- path com indexer, static member, sem getter publico ou colecao intermediaria; -- propriedade sem setter que nao possa ser associada a parametro de construtor publico; -- tipo sem construtor publico compativel com os membros mapeados; -- parametro de construtor sem coluna/membro correspondente; -- multiplos construtores publicos igualmente validos; -- prefix conflict como `Address` e `Address.City`; -- Value Object que nao pode ser criado pelo conjunto de colunas consultado. - -As mensagens incluem o tipo de entidade, `MemberPath`, tipo do Value Object, construtor quando aplicavel e colunas problemáticas. - -## Null Semantics - -A regra de `NULL` continua por subarvore: - -- se todos os valores de uma subarvore Value Object sao `NULL`, o Value Object fica `null`; -- se pelo menos um valor da subarvore nao e `NULL`, o Value Object e construido; -- valores `NULL` para parametros escalares reference/nullable chegam como `null`; -- valores `NULL` para value types nao anulaveis seguem o comportamento ja existente do materializer: default do tipo; -- NRT (`Cpf?`) nao e interpretado como metadata runtime nesta entrega. - -## Exceptions - -Construtores de dominio continuam sendo a autoridade para invariantes. - -Quando um construtor rejeita um valor, a excecao original e preservada como `InnerException` de `FluentMapConfigurationException`, com contexto adicional: - -- entidade; -- `MemberPath`; -- tipo do Value Object; -- construtor usado; -- coluna ou colunas envolvidas. - -Nao ha silencio de excecao nem criacao sem construtor. - -## Diagnostics - -`MappingMaterialization` recebeu: - -```csharp -ValueObject -``` - -`Explain()` agora distingue: - -- `Dapper` para root mapping regular; -- `Nested` para nested mutable materialization; -- `ValueObject` para paths aninhados que exigem construtor. - -Exemplo validado: - -```text -Cpf.Number - Column: cpf - Source: Explicit - Materialization: ValueObject -``` - -## Analyzer And Generator - -Analyzer: - -- nenhuma regra nova foi adicionada, porque construtores/factories e cobertura de colunas dependem do shape runtime da query; -- os testes existentes de analyzer foram preservados para garantir que nested/value-object member expressions continuam validas; -- regras estaticamente comprovaveis existentes (`DFM001` a `DFM005`) permanecem. - -Source generator: - -- nao foi transformado em materializer; -- o generator continua limitado a registro de maps; -- o smoke AOT recebeu um mapping de Value Object e valida `Explain` com `Materialization = ValueObject`; -- materializer gerado permanece estrategia futura para consumidores Native AOT/trimming que nao queiram usar `QueryMapped*` reflection-based. - -## AOT And Performance - -`QueryMapped*` continua anotado com: - -- `RequiresUnreferencedCode`; -- `RequiresDynamicCode`. - -Motivo: o caminho runtime usa reflection metadata e expression compilation para getters, setters, construtores e TypeHandler binding. - -Mitigacoes implementadas: - -- plano cacheado por tipo raiz + lista ordinal de colunas; -- selecao de construtor feita uma vez por plano; -- delegates de construtor/getter/setter/conversor pre-computados; -- sem lookup de construtor por row; -- sem expression tree criada por row. - -## Delivery - -Arquivos adicionados: - -- `test/Dapper.FluentMap.Tests/ValueObjectMaterializationTests.cs` -- `docs/sdd/etapa-5/03-value-objects.md` - -Arquivos alterados: - -- `README.md` -- `src/Dapper.FluentMap/Diagnostics/MappingMaterialization.cs` -- `src/Dapper.FluentMap/MappingConfigurationValidator.cs` -- `src/Dapper.FluentMap/MappingRegistry.cs` -- `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs` -- `src/Dapper.FluentMap/QueryMappedExtensions.cs` -- `test/Dapper.FluentMap.Tests/NestedMaterializationSpikeTests.cs` -- `test/Dapper.FluentMap.Tests/NestedObjectMaterializationTests.cs` -- `test/Dapper.FluentMap.AotSmoke/Program.cs` -- `docs/sdd/etapa-5/decisions.md` -- `docs/sdd/etapa-5/status.md` - -Nao foram alterados: - -- Dommel; -- targets; -- metadados NuGet; -- pacote do analyzer; -- source generator runtime. - -## Validation - -Validacao localizada executada durante a implementacao: - -```text -dotnet build .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Debug -dotnet build .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Debug -dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~ValueObjectMaterializationTests" -dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~ValueObjectMaterializationTests|FullyQualifiedName~NestedObjectMaterializationTests|FullyQualifiedName~NestedMaterializationSpikeTests|FullyQualifiedName~ConstructorMappingTests|FullyQualifiedName~DiagnosticsApiTests" -``` - -Resultados locais: - -- build do core: sucesso, 0 warnings, 0 erros; -- build dos testes do core: sucesso, 0 warnings, 0 erros; -- `ValueObjectMaterializationTests`: sucesso, 16 testes aprovados; -- conjunto relacionado: sucesso, 60 testes aprovados. - -Validacao final completa registrada ao concluir a entrega: - -```text -dotnet restore -dotnet build -dotnet test -dotnet build --configuration Release -dotnet test --configuration Release -dotnet pack .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release --no-build --output .\artifacts\packages -``` - -Resultado final: - -- `dotnet restore`: sucesso; -- `dotnet build`: sucesso, 0 warnings, 0 erros; -- `dotnet test`: sucesso, 166 testes do core, 7 Dommel, 7 analyzer, 12 generator e 1 generated-registration integration; -- `dotnet build --configuration Release`: sucesso, 0 warnings, 0 erros; -- `dotnet test --configuration Release`: sucesso com os mesmos 193 testes totais; -- `dotnet pack`: pacote `Dapper.FluentMap.2.0.0.nupkg` criado; warning legado `NU5125` sobre `PackageLicenseUrl`. - -Inspecao do pacote: - -- contem `lib/netstandard2.0/Dapper.FluentMap.dll`; -- contem `lib/netstandard2.0/Dapper.FluentMap.xml`; -- nao contem projetos de teste nem artefatos indevidos. - -Smokes especificos registrados ao concluir a entrega: - -```text -dotnet test .\test\Dapper.FluentMap.Analyzers.Tests\Dapper.FluentMap.Analyzers.Tests.csproj -dotnet test .\test\Dapper.FluentMap.Generators.Tests\Dapper.FluentMap.Generators.Tests.csproj -dotnet test .\test\Dapper.FluentMap.GeneratedRegistration.Tests\Dapper.FluentMap.GeneratedRegistration.Tests.csproj -dotnet run --project .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release -dotnet run --project .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:DefineConstants=AOT_SMOKE_GENERATED -dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishTrimmed=true -p:TrimMode=full -p:PublishAot=false -p:TrimmerSingleWarn=false -p:ILLinkTreatWarningsAsErrors=false -dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishTrimmed=true -p:TrimMode=full -p:PublishAot=false -p:DefineConstants=AOT_SMOKE_GENERATED -p:TrimmerSingleWarn=false -p:ILLinkTreatWarningsAsErrors=false -dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishAot=true -p:TrimmerSingleWarn=false -p:ILLinkTreatWarningsAsErrors=false -p:MSBuildWarningsAsMessages= -``` - -Resultados: - -- testes de analyzer: sucesso, 7 testes aprovados; -- testes de generator: sucesso, 12 testes aprovados; -- generated-registration integration: sucesso, 1 teste aprovado; -- AOT smoke explicit: `explicit:ok`; -- AOT smoke generated: `generated:ok`; -- publish trimmed explicit: sucesso, runtime `explicit:ok`, sem warnings FluentMap-owned; warnings restantes pertencem ao Dapper; -- publish trimmed generated: sucesso, runtime `generated:ok`, sem warnings FluentMap-owned; warnings restantes pertencem ao Dapper; -- publish Native AOT explicit: falhou no ambiente com `Platform linker not found`; runtime Native AOT nao foi validado. - -## Semantic Commit - -Mensagem: - -```text -feat: support immutable value object mappings -``` diff --git a/docs/sdd/etapa-5/04-mapping-profiles.md b/docs/sdd/etapa-5/04-mapping-profiles.md deleted file mode 100644 index b31131f..0000000 --- a/docs/sdd/etapa-5/04-mapping-profiles.md +++ /dev/null @@ -1,390 +0,0 @@ -# 04 - Mapping Profiles - -## Specification - -O problema desta entrega e permitir que a mesma entidade seja materializada a partir de shapes SQL distintos sem trocar o `ITypeMap` global do Dapper durante a operacao. - -Exemplo: - -```sql -SELECT customer_id, customer_name -SELECT id, legal_name -``` - -Ambas podem materializar `Customer`, mas exigem mappings diferentes. - -Requisitos preservados: - -- `connection.Query(sql)` continua usando o mapping default registrado por `AddMap(...)`; -- profiles sao opt-in por operacao; -- nenhuma query troca `SqlMapper.SetTypeMap(...)` temporariamente; -- queries simultaneas com profiles diferentes nao vazam mappings; -- nested mappings e Value Objects imutaveis continuam usando o caminho `QueryMapped*`; -- analyzer, source generator e `Explain` distinguem default de profiles. - -## Discovery - -Arquivos analisados: - -- `AGENTS.md` -- `.agents/skills/run-tests/SKILL.md` -- `.agents/skills/dotnet-aot-compat/SKILL.md` -- `docs/sdd/etapa-5/README.md` -- `docs/sdd/etapa-5/status.md` -- `docs/sdd/etapa-5/decisions.md` -- `docs/sdd/etapa-5/01-nested-materialization-spike.md` -- `docs/sdd/etapa-5/02-nested-object-materialization.md` -- `docs/sdd/etapa-5/03-value-objects.md` -- `docs/sdd/etapa-1/04-mapping-registry-cache.md` -- `docs/sdd/etapa-3/01-mapping-registration.md` -- `docs/sdd/etapa-3/03-diagnostics-api.md` -- `docs/sdd/etapa-4/02-trimming-aot.md` -- `docs/sdd/etapa-4/03-source-generator.md` -- `src/Dapper.FluentMap/MappingRegistry.cs` -- `src/Dapper.FluentMap/QueryMappedExtensions.cs` -- `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs` -- `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs` -- `src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs` -- `src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs` - -Entregas anteriores confirmadas: - -```text -01 - Spike nested/value-object -> Concluido, commit ff64f96 -02 - Nested object materialization -> Concluido, commit 2ed4af5 -03 - Value Objects imutaveis -> Concluido, commit 68c9959 -``` - -Fontes primarias do Dapper 2.1.79 analisadas: - -- `SqlMapper.ITypeMap.cs`: https://github.com/DapperLib/Dapper/blob/72a54c475f75e18cb93cba0809d00a5e6e49efd9/Dapper/SqlMapper.ITypeMap.cs -- `SqlMapper.cs`: https://github.com/DapperLib/Dapper/blob/72a54c475f75e18cb93cba0809d00a5e6e49efd9/Dapper/SqlMapper.cs -- `SqlMapper.Async.cs`: https://github.com/DapperLib/Dapper/blob/72a54c475f75e18cb93cba0809d00a5e6e49efd9/Dapper/SqlMapper.Async.cs - -Conclusoes sobre o Dapper: - -- o `ITypeMap` e resolvido por `Type`, nao por operacao; -- `CommandDefinition` transporta SQL, parametros, transaction, timeout, command type, flags e cancellation token, mas nao um type map por query; -- as APIs async do Dapper tambem materializam por `Type` e cache interno do Dapper; -- multi-mapping usa `splitOn` e callbacks de composicao, mas nao representa `MemberPath` nem profile identity; -- nao ha API publica no Dapper 2.1.79 para fornecer `ITypeMap` ou materializer customizado por operacao. - -## Alternatives - -### A - Mutation scope - -Modelo: - -```text -SetTypeMap(profile A) -Query() -SetTypeMap(profile B) -``` - -Rejeitada. - -Motivos: - -- `SqlMapper.SetTypeMap` altera estado global por `Type`; -- duas queries simultaneas poderiam observar o profile errado; -- async pode suspender e retomar em outro momento enquanto outro profile foi instalado; -- caches internos do Dapper podem ser aquecidos com uma identidade de type map que nao representa a operacao seguinte; -- exigiria lock global por entidade e reduziria concorrencia, alem de continuar vulneravel a consumidores externos chamando Dapper diretamente. - -### B - Query wrapper - -Aceita como superficie publica. - -`QueryMapped*` ja era a API opt-in da Etapa 5 para materializacao controlada pelo FluentMap. Esta entrega a estende com overloads tipados por profile. - -### C - Custom materializer da Etapa 5 - -Aceita como implementacao. - -`NestedMaterializationPlan` ja controla `DbDataReader`, `MemberPath`, null semantics, construtores e Value Objects. Profiles passam a selecionar outro conjunto de mappings antes de criar/cachear o plano. - -### D - Generated query/materializer - -Adiada. - -O source generator atual gera registro, nao leitura de `DbDataReader`. Materializers gerados continuam sendo o caminho futuro preferencial para performance e Native AOT, mas nao sao necessarios para entregar selecao query-scoped segura. - -### E - Dapper API publica existente - -Nao encontrada no Dapper 2.1.79. - -As APIs publicas de `Query`, `QueryAsync`, `ExecuteReader`, `ExecuteReaderAsync`, `CommandDefinition` e multi-mapping nao aceitam type map/materializer por operacao. - -## Decision - -API escolhida: - -```csharp -public interface IMappingProfile -{ -} - -public interface IProfileMap - where TProfile : IMappingProfile -{ -} - -configuration.AddProfile(); - -connection.QueryMapped(sql); -connection.QueryMappedSingle(sql); -connection.QueryMappedAsync(sql); -connection.QueryMappedSingleAsync(sql); - -FluentMapper.Explain(); -``` - -Exemplo: - -```csharp -public sealed class LegacyProfile : IMappingProfile -{ -} - -public sealed class LegacyCustomerMap : - EntityMap, - IProfileMap -{ - public LegacyCustomerMap() - { - Map(customer => customer.Id).ToColumn("id"); - Map(customer => customer.Name).ToColumn("legal_name"); - } -} -``` - -Identidade do profile: - -- fortemente tipada por marker `TProfile`; -- um map de profile implementa exatamente um `IProfileMap`; -- a entidade continua sendo inferida por `IEntityMap`; -- strings nao foram usadas para evitar typos silenciosos. - -Modelo de registry: - -```text -EntityType - Default map: EntityMaps[EntityType] - Profile maps: ProfileMaps[(EntityType, ProfileType)] - Conventions/naming policies: TypeConventions[EntityType] -``` - -Precedencia efetiva no caminho de profile: - -```text -Profile explicit -Profile inherited no mesmo TProfile -Entity conventions/naming policies atuais -Dapper/default behavior -``` - -Conventions e naming policies continuam registradas por entidade, nao por profile, nesta entrega. Elas sao aplicadas de forma read-only tambem em profiles e podem ser sobrescritas por mappings explicitos do profile. Per-profile conventions ficam como divida futura, porque exigem uma API adicional e regras proprias de composicao. - -Inheritance: - -- `IncludeBase()` em um default map continua procurando o default map da base; -- `IncludeBase()` em um profile map procura a base no mesmo `TProfile`; -- nao ha mistura silenciosa de default base map dentro de um profile alternativo. - -Cache: - -- `MappingCacheKey` agora inclui `ProfileType`; -- `MaterializationPlanCacheKey` agora inclui `ProfileType`; -- o profile e resolvido antes do loop de leitura; -- nao ha lookup textual por row; -- registro/reset invalidam planos por entidade. - -Thread/async safety: - -- a selecao de profile esta nos generics da operacao; -- nenhum `AsyncLocal`, thread-static ou mutacao global e usado para selecionar profile; -- o Dapper type map global continua representando apenas o default; -- `QueryMappedAsync*` usa `CommandDefinition` e `ExecuteReaderAsync`, preservando cancellation token quando o consumidor passa o command overload. - -Compatibilidade: - -- `AddMap(...)`, `AddMap()`, `Dapper.Query()`, `QueryMapped()` e `Explain()` foram preservados; -- `Dapper.Query()` nao ve profiles; -- profiles nao sao registrados por `SqlMapper.SetTypeMap`. - -Limitacoes: - -- profiles sao suportados no caminho `QueryMapped*`, nao em `Dapper.Query()`; -- multi-mapping do Dapper nao recebeu overload de profile; -- unbuffered streaming nao foi implementado; os overloads retornam lista materializada como o `QueryMapped()` existente; -- per-profile conventions/naming policies ficam para etapa futura; -- generator continua sendo registration generator, nao materializer generator. - -## Delivery - -Arquivos adicionados: - -- `src/Dapper.FluentMap/MappingProfileKey.cs` -- `test/Dapper.FluentMap.Tests/MappingProfileTests.cs` -- `docs/sdd/etapa-5/04-mapping-profiles.md` - -Arquivos alterados: - -- `README.md` -- `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs` -- `src/Dapper.FluentMap/Diagnostics/MappingExplanation.cs` -- `src/Dapper.FluentMap/FluentMapper.cs` -- `src/Dapper.FluentMap/Mapping/EntityMap.cs` -- `src/Dapper.FluentMap/MappingCacheKey.cs` -- `src/Dapper.FluentMap/MappingRegistry.cs` -- `src/Dapper.FluentMap/Materialization/MaterializationPlanCacheKey.cs` -- `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs` -- `src/Dapper.FluentMap/QueryMappedExtensions.cs` -- `src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Unshipped.md` -- `src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs` -- `src/Dapper.FluentMap.Generators/AnalyzerReleases.Unshipped.md` -- `src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs` -- `test/Dapper.FluentMap.Analyzers.Tests/FluentMapConfigurationAnalyzerTests.cs` -- `test/Dapper.FluentMap.AotSmoke/Program.cs` -- `test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs` -- `test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs` -- `docs/sdd/etapa-5/README.md` -- `docs/sdd/etapa-5/status.md` -- `docs/sdd/etapa-5/decisions.md` - -Analyzer: - -- `DFM009`: `AddProfile()` com map sem exatamente um `IEntityMap` e um `IProfileMap`; -- `DFM010`: duas chamadas conhecidas ao mesmo entity/profile no mesmo metodo de configuracao; -- IDs existentes `DFM001` a `DFM005` foram preservados. - -Source generator: - -- default maps continuam gerando `.AddMap()`; -- profile maps geram `.AddProfile()`; -- `DFM007` continua valendo apenas para mais de um default map da mesma entidade; -- `DFM008` detecta mais de um generated profile map para a mesma entidade e o mesmo profile. - -## Tests - -Testes novos cobrem: - -- default mapping via `Dapper.Query()`; -- profile alternativo via `QueryMapped()`; -- duas queries sequenciais com profiles diferentes; -- default depois de profile; -- queries paralelas sync com profiles distintos; -- queries async concorrentes com profiles distintos; -- nested mappings em profiles; -- Value Objects em profiles; -- inheritance no mesmo profile marker; -- naming policy de entidade aplicada no profile; -- constructor mapping em profile; -- profile inexistente; -- duplicidade de profile; -- `Explain()`; -- generator com profile; -- generator rejeitando profile duplicado; -- analyzer validando `AddProfile()`; -- analyzer rejeitando registro duplicado conhecido. - -## Performance - -Comparacao arquitetural: - -| Caminho | Resolucao por operacao | Hot path por row | -|---|---|---| -| Dapper/FluentMap default | Dapper resolve type map/cache por `Type` e shape | IL/materializer do Dapper | -| QueryMapped default | plano cacheado por entidade + colunas | delegates precomputados | -| QueryMapped profile | plano cacheado por entidade + profile + colunas | delegates precomputados | - -Overhead esperado do profile: - -- uma chave de cache maior; -- um lookup de `ProfileMaps[(EntityType, ProfileType)]` ao criar o plano; -- nenhum lookup extra por row em relacao ao `QueryMapped()` default. - -Nao foi adicionado benchmark formal nesta entrega. O criterio de aceite foi garantir ausencia de vazamento em concorrencia e evitar resolucao textual por row. - -## AOT And Trimming - -- `QueryMapped*` continua anotado com `RequiresUnreferencedCode` e `RequiresDynamicCode`, porque usa reflection e expression compilation; -- `AddProfile()` segue o mesmo modelo de `AddMap()`, com inferencia por interfaces anotada; -- source generation de registro suporta profiles e evita assembly scanning; -- smoke AOT/trimming valida registro/explain de profile nos caminhos explicit e generated; -- Native AOT runtime completo permanece nao validado no ambiente por ausencia do platform linker C++. - -## Validation - -Validacoes localizadas ja executadas durante a implementacao: - -```text -dotnet build .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Debug -dotnet build .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Debug -dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --filter "FullyQualifiedName~MappingProfileTests" -dotnet test .\test\Dapper.FluentMap.Analyzers.Tests\Dapper.FluentMap.Analyzers.Tests.csproj -dotnet test .\test\Dapper.FluentMap.Generators.Tests\Dapper.FluentMap.Generators.Tests.csproj -dotnet test .\test\Dapper.FluentMap.GeneratedRegistration.Tests\Dapper.FluentMap.GeneratedRegistration.Tests.csproj -``` - -Resultados: - -- core: sucesso, 0 warnings, 0 erros; -- testes do core: sucesso, 0 warnings, 0 erros; -- `MappingProfileTests`: sucesso, 15 testes aprovados; -- analyzer: sucesso, 9 testes aprovados; -- generator: sucesso, 14 testes aprovados; -- generated-registration integration: sucesso, 1 teste aprovado. - -Validacao final completa deve registrar: - -```text -dotnet restore -dotnet build -dotnet test -dotnet build --configuration Release -dotnet test --configuration Release -``` - -Resultado final: - -- `dotnet restore`: sucesso; -- `dotnet build`: sucesso, 0 warnings, 0 erros; -- `dotnet test`: sucesso, 181 testes do core, 7 Dommel, 9 analyzer, 14 generator e 1 generated-registration integration; -- `dotnet build --configuration Release`: sucesso, 0 warnings, 0 erros; -- `dotnet test --configuration Release`: sucesso com os mesmos 212 testes totais; -- `dotnet pack` do core: pacote `Dapper.FluentMap.2.0.0.nupkg` criado; warning legado `NU5125` sobre `PackageLicenseUrl`; -- `dotnet pack` do analyzer e generator: pacotes criados com sucesso. - -Inspecao de pacotes: - -- core contem `lib/netstandard2.0/Dapper.FluentMap.dll` e XML docs; -- generator contem `README.md` e `analyzers/dotnet/cs/Dapper.FluentMap.Generators.dll`, sem `lib/`; -- analyzer contem `README.md` e `analyzers/dotnet/cs/Dapper.FluentMap.Analyzers.dll`, sem `lib/`; -- nenhum pacote contem projetos de teste ou artefatos indevidos. - -Smokes AOT/trimming: - -```text -dotnet run --project .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release -dotnet run --project .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:DefineConstants=AOT_SMOKE_GENERATED -dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishTrimmed=true -p:TrimMode=full -p:PublishAot=false -p:TrimmerSingleWarn=false -p:ILLinkTreatWarningsAsErrors=false -dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishTrimmed=true -p:TrimMode=full -p:PublishAot=false -p:DefineConstants=AOT_SMOKE_GENERATED -p:TrimmerSingleWarn=false -p:ILLinkTreatWarningsAsErrors=false -dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishAot=true -p:TrimmerSingleWarn=false -p:ILLinkTreatWarningsAsErrors=false -p:MSBuildWarningsAsMessages= -``` - -Resultados: - -- AOT smoke explicit: `explicit:ok`; -- AOT smoke generated: `generated:ok`; -- publish trimmed explicit: sucesso, runtime `explicit:ok`, sem warnings FluentMap-owned; warnings restantes pertencem ao Dapper; -- publish trimmed generated: sucesso, runtime `generated:ok`, sem warnings FluentMap-owned; warnings restantes pertencem ao Dapper; -- publish Native AOT explicit: falhou no ambiente com `Platform linker not found`; runtime Native AOT nao foi validado. - -## Semantic Commit - -Mensagem planejada: - -```text -feat: add query-scoped mapping profiles -``` diff --git a/docs/sdd/etapa-5/README.md b/docs/sdd/etapa-5/README.md deleted file mode 100644 index 23d87a8..0000000 --- a/docs/sdd/etapa-5/README.md +++ /dev/null @@ -1,135 +0,0 @@ -# Etapa 5 - -## Objetivo - -Investigar e evoluir o `Dapper.FluentMap` para suportar, de forma segura e opt-in, materializacao de objetos aninhados, Value Objects imutaveis e perfis de mapping, sem transformar a biblioteca em ORM, query builder ou camada de CRUD. - -## Dependencia Das Etapas 1 A 4 - -Esta etapa depende das decisoes anteriores sobre: - -- `MemberPath` como identidade interna de caminho; -- `MappingRegistry`, cache estruturado e precedencia efetiva; -- constructor mapping para propriedades simples; -- records e tipos imutaveis simples; -- API publica `Validate()` e `Explain()`; -- limites atuais do `ITypeMap` do Dapper; -- trimming, Native AOT e source generation. - -Nenhuma decisao das etapas anteriores deve ser revertida sem evidencia tecnica registrada nesta pasta. - -## Leitura Obrigatoria - -Antes de iniciar qualquer entrega desta etapa, leia: - -- `docs/sdd/etapa-1/README.md` -- `docs/sdd/etapa-1/decisions.md` -- `docs/sdd/etapa-2/README.md` -- `docs/sdd/etapa-2/decisions.md` -- `docs/sdd/etapa-2/01-member-path.md` -- `docs/sdd/etapa-2/03-inherited-mappings.md` -- `docs/sdd/etapa-3/README.md` -- `docs/sdd/etapa-3/decisions.md` -- `docs/sdd/etapa-3/02-constructor-immutable-mapping.md` -- `docs/sdd/etapa-3/03-diagnostics-api.md` -- `docs/sdd/etapa-4/README.md` -- `docs/sdd/etapa-4/decisions.md` -- `docs/sdd/etapa-4/02-trimming-aot.md` -- `docs/sdd/etapa-4/03-source-generator.md` -- `docs/sdd/etapa-5/decisions.md` -- relatorios ja concluidos em `docs/sdd/etapa-5/`. - -## Escopo - -O escopo padrao continua sendo o projeto principal `Dapper.FluentMap` e seus testes. - -`Dapper.FluentMap.Dommel` nao deve receber alteracao funcional nesta etapa, salvo se uma mudanca comprovada no core exigir adaptacao explicita e documentada. - -## Compatibilidade - -- Preserve a API publica existente sempre que possivel. -- Preserve `netstandard2.0` nos projetos de `src/`. -- Nao altere os TargetFrameworks atuais sem decisao arquitetural futura e especifica. -- Nao mude o comportamento de `Dapper.Query` para prometer nested materialization implicitamente. -- Qualquer nova capacidade de materializacao aninhada deve ser opt-in e testada com Dapper real. - -## Fora Do Escopo - -Esta etapa nao deve transformar o FluentMap em: - -- ORM; -- query builder; -- gerador de SQL; -- camada de CRUD; -- change tracker; -- unit of work. - -## Entregas - -1. 01 - Spike de nested/value-object materialization -2. 02 - Nested object materialization -3. 03 - Value Objects imutaveis -4. 04 - Mapping profiles - -## Resultado da Etapa 5 - -Capacidades entregues: - -- nested mapping opt-in por `QueryMapped()` e `QueryMappedSingle()`, preservando `Dapper.Query()` para o comportamento default; -- `MemberPath` preservado como identidade completa de paths como `Address.City`, `Rank.Level` e `Seniority.Level`; -- null semantics por subarvore: subarvore toda `NULL` resulta em intermediario/value object `null`; subarvore parcialmente preenchida cria o objeto; -- Value Objects imutaveis e nested immutable objects por construtores publicos compativeis, sem setters privados, fields ou bypass de invariantes; -- strategy de constructor/factory limitada a construtores publicos; factory methods permanecem fora do escopo; -- TypeHandler integration preservada para Value Objects escalares mapeados como propriedade inteira; -- mapping profiles query-scoped por `TProfile : IMappingProfile`, registrados por `AddProfile()` e selecionados por `QueryMapped()`; -- concorrencia validada para profiles distintos em queries sync e async simultaneas, sem troca de `SqlMapper.SetTypeMap`; -- `Explain()` para default e `Explain()` para profile, incluindo `Materialization` e `ProfileType`; -- source generator atualizado para gerar `AddMap()` ou `AddProfile()` conforme o map; -- analyzer atualizado com diagnostics determinaveis para profile invalid/duplicado. - -Compatibilidade: - -- `Dapper.Query()`, `AddMap(...)`, `AddMap()`, conventions, naming policies, constructor mapping simples e fallback do Dapper continuam preservados; -- o core continua `netstandard2.0`; -- Dommel nao recebeu alteracao funcional nesta etapa. - -AOT/trimming: - -- `QueryMapped*` continua runtime/reflection-based e anotado com `RequiresUnreferencedCode` e `RequiresDynamicCode`; -- registro explicito e gerado sao os caminhos recomendados para consumidores trimmed; -- source generation ainda gera registro, nao materializer de `DbDataReader`; -- Native AOT runtime completo nao foi validado neste ambiente por ausencia do platform linker C++. - -Limitacoes: - -- `QueryMapped*` materializa em lista, sem streaming unbuffered; -- profiles nao se aplicam a `Dapper.Query()` nem a multi-mapping do Dapper; -- conventions e naming policies ainda sao por entidade, nao por profile; -- factory methods, private constructors, private setters e field injection continuam fora do contrato; -- materializer gerado permanece futuro. - -## Dividas e proximos passos - -### P0 - -- Nenhum item P0 registrado ao encerrar a Etapa 5. - -### P1 - -- Criar materializer gerado para `DbDataReader`, cobrindo nested mappings, Value Objects e profiles sem reflection no hot path. -- Definir suporte a per-profile conventions/naming policies antes de ampliar a composicao de policies. -- Avaliar streaming/unbuffered para `QueryMapped*` com lifetime claro de connection/reader. - -### P2 - -- Adicionar benchmarks formais comparando Dapper default, `QueryMapped()` e `QueryMapped()`. -- Expandir overloads async/default de `QueryMapped*` de forma simetrica, se houver demanda publica. -- Melhorar diagnostics de profile inexistente em analyzer somente quando a ausencia puder ser comprovada sem falso positivo cross-assembly. -- Avaliar API publica de factory methods para Value Objects com regras de ambiguidade e validacao. - -### Research - -- Investigar Native AOT runtime completo em ambiente com platform linker C++ instalado. -- Avaliar integracao futura com APIs publicas novas do Dapper caso surja suporte a materializer/type map por operacao. -- Avaliar modelo de cache imutavel/snapshot para reduzir dependencia de estado global historico. -- Revisar Dommel em etapa propria para decidir se profiles devem ou nao ser visiveis em integrações CRUD externas. diff --git a/docs/sdd/etapa-5/decisions.md b/docs/sdd/etapa-5/decisions.md deleted file mode 100644 index dbce893..0000000 --- a/docs/sdd/etapa-5/decisions.md +++ /dev/null @@ -1,56 +0,0 @@ -# Decisoes Da Etapa 5 - -Registre aqui apenas decisoes arquiteturais necessarias as proximas entregas. - -## Nested Materialization - -- `MemberPath` continua sendo identidade e diagnostico de caminho; ele nao deve ser entregue diretamente ao Dapper como `PropertyInfo` terminal para simular nested assignment. -- `Dapper.Query` com o `ITypeMap` atual do Dapper permanece suportado para mappings simples, constructor mapping simples, conventions, naming policies e fallback. -- Nested object materialization e opt-in por uma API paralela de consulta/materializacao: `QueryMapped` e `QueryMappedSingle`. -- O caminho opt-in le valores do reader e aplica um plano de materializacao baseado em `MemberPath`. -- Nested paths nao sao tratados como propriedades simples pelo type map instalado no Dapper, porque isso pode escrever o valor do leaf no slot errado do objeto raiz. -- A Entrega 2 suporta objetos aninhados mutaveis com construtor publico sem parametros e propriedades publicamente settable. -- A semantica de `NULL` e por subarvore: quando todos os valores nested de uma subarvore sao `NULL`, o intermediario fica `null`; quando algum valor nao e `NULL`, o intermediario e criado ou reutilizado. -- `Explain()` representa nested mappings com `Materialization = Nested`. -- Prefix conflicts como `Address` e `Address.City` no mesmo plano sao rejeitados. - -## Value Objects - -- Value Objects escalares devem usar o mecanismo publico de TypeHandlers do Dapper quando o mapping aponta para a propriedade Value Object inteira, por exemplo `Map(x => x.Cpf).ToColumn("cpf")`. -- TypeHandler nao resolve nested path arbitrario como `Map(x => x.Cpf.Number).ToColumn("cpf")`, porque o Dapper passa a converter e atribuir o membro terminal (`Number`), nao o Value Object (`Cpf`). -- Value Objects imutaveis dentro de grafos aninhados exigem materializacao controlada pelo FluentMap ou geracao de materializer; nao devem ser declarados suportados por `ITypeMap` puro. -- `QueryMapped*` suporta nested Value Objects por construtores publicos quando todos os parametros exigidos correspondem a propriedades mapeadas ou objetos aninhados mapeados. -- Factory methods como `Cpf.Create(...)` nao foram implementados nesta etapa; qualquer suporte futuro deve ser API publica explicita, fortemente tipada e com regras de ambiguidade proprias. -- Nao ha suporte a private constructor, private setter, field injection, `FormatterServices` ou alteracao de backing field. -- A semantica de `NULL` para Value Object e por subarvore: se todas as colunas da subarvore sao `NULL`, o Value Object resultante e `null`; se alguma coluna possui valor, o construtor publico e usado. -- Excecoes de dominio lancadas por construtores sao preservadas como `InnerException` de `FluentMapConfigurationException` com contexto de entidade, `MemberPath`, tipo, construtor e colunas. - -## Records E Imutabilidade - -- Records posicionais e classes imutaveis simples continuam sendo responsabilidade do constructor mapping existente quando todos os parametros sao simples. -- Nested records, nested immutable objects e construcao de Value Objects por construtor devem ser tratados por uma estrategia complementar ao `ITypeMap` do Dapper. -- Nested records e nested immutable objects passam a ser suportados no caminho opt-in `QueryMapped*` quando a arvore completa pode ser construida por construtores publicos compativeis. -- Mappings simples de records/classes imutaveis via `Dapper.Query` continuam preservados pelo constructor mapping da Etapa 3. - -## Source Generation, Trimming E AOT - -- O generator da Etapa 4 continua limitado a registro de mappings. -- Um materializer gerado pode ser uma estrategia futura para performance, trimming e Native AOT, mas nao deve ser acoplado a Entrega 2 como unico caminho. -- O caminho runtime/reflection-based de `QueryMapped*` e documentado como menos AOT-friendly e foi anotado com `RequiresUnreferencedCode` e `RequiresDynamicCode`; o caminho gerado deve ser a opcao preferencial para consumidores trimmed/AOT quando existir. -- A Entrega 3 nao amplia o generator para materializar `DbDataReader`; o smoke AOT valida registro/diagnostico de Value Object, nao runtime AOT completo de `QueryMapped*`. - -## Mapping Profiles - -- Multiple mapping profiles por tipo sao suportados apenas no caminho opt-in `QueryMapped*`; `Dapper.Query` continua usando o mapping default registrado por `AddMap(...)`. -- A identidade de profile e fortemente tipada por marker `TProfile : IMappingProfile`; maps de profile implementam `IProfileMap`. -- A API de registro escolhida e `configuration.AddProfile()`, inferindo a entidade por `IEntityMap` e o profile por `IProfileMap`. -- A API de consulta escolhida e query-scoped: `QueryMapped(...)`, `QueryMappedSingle(...)`, `QueryMappedAsync(...)` e `QueryMappedSingleAsync(...)`. -- `SqlMapper.SetTypeMap` nao e usado para profiles; o type map global do Dapper permanece representando apenas o default. -- O registry passa a modelar `EntityMaps[EntityType]` para default e `ProfileMaps[(EntityType, ProfileType)]` para profiles. -- `MappingCacheKey` e `MaterializationPlanCacheKey` incluem `ProfileType`, evitando reutilizacao de planos entre profiles. -- `IncludeBase()` dentro de profile map procura a base no mesmo `TProfile`; nao ha heranca silenciosa do default dentro de profile alternativo. -- Conventions e naming policies continuam por entidade e sao aplicadas de forma read-only tambem em profiles; per-profile conventions ficam como divida futura. -- `Explain()` continua descrevendo o default; `Explain()` descreve o profile e expoe `MappingExplanation.ProfileType`. -- O source generator distingue default maps de profile maps: default gera `AddMap()`, profile gera `AddProfile()`; duplicidade de profile gerada usa `DFM008`. -- O analyzer adiciona `DFM009` para `AddProfile()` invalido e `DFM010` para duplicidade conhecida de entity/profile no mesmo metodo de configuracao. -- Profiles nao implementam multi-mapping, streaming unbuffered nem materializer gerado nesta entrega. diff --git a/docs/sdd/etapa-5/status.md b/docs/sdd/etapa-5/status.md deleted file mode 100644 index 653c304..0000000 --- a/docs/sdd/etapa-5/status.md +++ /dev/null @@ -1,6 +0,0 @@ -| Entrega | Status | Commit | -|---|---|---| -| 01 - Spike nested/value-object | Concluido | ff64f96 | -| 02 - Nested object materialization | Concluido | 2ed4af5 | -| 03 - Value Objects imutaveis | Concluido | 68c9959 | -| 04 - Mapping profiles | Concluido | 65e5fd3 | diff --git a/docs/sdd/etapa-6/01-configuration-lifecycle.md b/docs/sdd/etapa-6/01-configuration-lifecycle.md deleted file mode 100644 index d9622a3..0000000 --- a/docs/sdd/etapa-6/01-configuration-lifecycle.md +++ /dev/null @@ -1,283 +0,0 @@ -# 01 - Configuration Lifecycle - -## Current Behavior - -`FluentMapper` e a fachada publica global do core. Ela possui: - -- `_registry`: instancia estatica de `MappingRegistry`; -- `_configuration`: instancia estatica de `FluentMapConfiguration`; -- `EntityMaps`: campo publico `ConcurrentDictionary` apontando para o storage do registry; -- `TypeConventions`: campo publico `ConcurrentDictionary>` apontando para o storage do registry. - -`FluentMapper.Initialize(Action)` nao cria snapshot e nao marca a configuracao como concluida. Ele apenas executa o callback recebido sobre a mesma instancia estatica de `FluentMapConfiguration`. - -As APIs publicas que podem alterar configuracao sao: - -- `FluentMapper.Initialize(...)`; -- `FluentMapConfiguration.AddMap(IEntityMap)`; -- `FluentMapConfiguration.AddMap()`; -- `FluentMapConfiguration.AddProfile()`; -- `FluentMapConfiguration.AddMapsFromAssembly(...)`; -- `FluentMapConfiguration.AddMapsFromAssemblyContaining()`; -- `FluentMapConfiguration.AddConvention()` combinado com `ForEntity(...)`, `ForEntitiesInAssembly(...)` ou `ForEntitiesInCurrentAssembly(...)`; -- `FluentMapConfiguration.UseNamingPolicy(...)` combinado com os mesmos destinos de convention; -- `FluentMapConfigurationExtensions.ApplyMapsFromAssemblies(...)`; -- mutacao direta de `FluentMapper.EntityMaps`; -- mutacao direta de `FluentMapper.TypeConventions`. - -As estruturas static/global atuais sao: - -- `FluentMapper._registry`; -- `FluentMapper._configuration`; -- `FluentMapper.EntityMaps`; -- `FluentMapper.TypeConventions`; -- caches internos de `MappingRegistry`; -- registro global de type maps do Dapper via `SqlMapper.SetTypeMap`; -- cache legado protegido `MultiTypeMap.TypePropertyMapCache`, preservado por compatibilidade mas nao usado pelo core atual. - -`SqlMapper.SetTypeMap` e chamado em: - -- `MappingRegistry.AddEntityMap(...)`, depois de validar e adicionar um default map; -- `MappingRegistry.AddConvention(...)`, depois de adicionar uma convention/naming policy; -- `MappingRegistry.Reset(...)`, para remover type maps dos tipos informados nos testes; -- testes de caracterizacao que instalam type maps customizados diretamente. - -`SqlMapper.SetTypeMap` nao e chamado por `AddProfile()` e nao e chamado por `QueryMapped()`. - -Invalidacao de cache atual: - -- `AddEntityMap(...)` invalida entradas de property-map e materialization-plan cache do tipo e reinstala o type map do Dapper; -- `AddProfileMap(...)` invalida caches do tipo, mas nao troca o type map global do Dapper; -- `AddConvention(...)` invalida caches do tipo e reinstala o type map do Dapper; -- `Reset(...)` limpa maps, profiles, conventions, property-map cache, materialization-plan cache e remove type maps do Dapper para os tipos informados; -- mutacao direta de `EntityMaps` ou `TypeConventions` nao passa pelo registry e pode bypassar validacao, invalidacao e instalacao de type map. - -Profiles evitam mutacao global porque sao armazenados em `MappingRegistry.ProfileMaps[(EntityType, ProfileType)]` e selecionados pelo caminho `QueryMapped()`. A chave de cache inclui `ProfileType`, e o materializer resolve o profile antes do loop de leitura. O default type map do Dapper permanece representando apenas a configuracao default. - -Os testes resetam estado por `FluentMapper.Reset(...)`, que e interno e visivel ao assembly de testes. A suite principal, Dommel e generated-registration desabilitam paralelismo porque FluentMap e Dapper compartilham estado global por processo. - -Nao existe atualmente nenhum conceito publico ou interno de `configuration completed`, `freeze`, `sealed`, `initialized` ou equivalente. Chamadas repetidas de `Initialize(...)` sao permitidas quando adicionam configuracao valida e falham pelas regras existentes quando duplicam default maps ou profiles. - -Nao foi encontrada documentacao historica prometendo runtime reconfiguration concorrente. A documentacao existente recomenda inicializacao por `FluentMapper.Initialize(...)`, valida estado global por `Validate()` e usa profiles query-scoped para evitar troca temporaria de `SqlMapper.SetTypeMap`. - -## Problem - -O FluentMap depende de estado global/static proprio e do registro global de `ITypeMap` do Dapper. Isso e compativel com o uso historico de configurar uma vez no startup e consultar depois, mas e ambiguo para consumidores que interpretam `Initialize(...)`, conventions ou dicionarios publicos como API de reconfiguracao dinamica durante a execucao. - -O risco arquitetural e que queries concorrentes observem configuracoes diferentes para o mesmo tipo, ou que caches internos e o registro global do Dapper sejam alterados enquanto materializers estao em uso. - -## Supported Lifecycle - -O lifecycle suportado passa a ser: - -```text -Configuration Phase - | - v -Operational Phase -``` - -### Configuration Phase - -Fase esperada durante startup da aplicacao ou antes do primeiro uso dos tipos configurados. - -Permitido: - -- registrar default maps; -- registrar profiles; -- registrar conventions e naming policies; -- usar assembly scanning quando apropriado para runtime normal; -- chamar `Validate()`; -- chamar `Explain()` para diagnostico; -- chamar `Initialize(...)` mais de uma vez para configuracao aditiva, desde que cada chamada respeite as regras de duplicidade e validacao existentes. - -### Operational Phase - -Comeca quando a aplicacao passa a executar queries que podem usar FluentMap ou o type map global do Dapper para os tipos configurados. - -Permitido como operacao normal: - -- `Dapper.Query()` usando o default type map ja instalado; -- `QueryMapped()` usando o default registry snapshot efetivo no momento de criar o plano; -- `QueryMapped()` selecionando profile por operacao; -- `Validate()` e `Explain<...>()` como leituras diagnosticas sem side effects intencionais. - -Durante esta fase, consumidores devem tratar a configuracao efetiva como read-only. - -### Compatibility Runtime Mutation - -Por compatibilidade, as APIs publicas atuais continuam podendo registrar maps, profiles e conventions depois de queries ja terem ocorrido. Esse uso e suportado apenas quando o consumidor garante quiescencia externa para os tipos afetados: sem queries concorrentes, sem materializers em execucao e sem outro componente alterando `SqlMapper.SetTypeMap`. - -Nao ha garantia de determinismo para reconfiguracao concorrente em runtime. - -Mutacao direta de `EntityMaps` e `TypeConventions` permanece uma superficie legada de compatibilidade, mas nao faz parte do caminho suportado para configuracao deterministica. Ela pode bypassar validacao, invalidacao de cache e instalacao do type map do Dapper. - -## Invariants - -- Queries nao devem depender de configuracao sendo alterada simultaneamente. -- Configuracao estabelecida antes da fase operacional deve produzir comportamento deterministico. -- `AddMap(...)`, conventions e naming policies aplicadas pelo registry devem invalidar caches do tipo afetado. -- `AddProfile()` deve invalidar planos do tipo, mas nao trocar o type map global do Dapper. -- Profiles devem permanecer query-scoped. -- Dapper global state nao deve ser trocado temporariamente para implementar profiles. -- `Validate()` e `Explain<...>()` devem permanecer leituras diagnosticas sem instalacao de type maps ou invalidacao de caches. -- Compatibilidade existente nao deve ser quebrada silenciosamente. -- Dicionarios publicos mutaveis nao devem ser tratados como caminho recomendado de configuracao nova. - -## Goals - -- Documentar o lifecycle oficial suportado. -- Distinguir configuracao normal, operacao read-only e mutacao legada compatibilizada. -- Registrar que runtime reconfiguration concorrente nao e contrato publico. -- Preservar source/binary compatibility. -- Preparar a Entrega 02 para encapsular estado sem assumir que mutabilidade publica pode ser removida imediatamente. -- Proteger o contrato com testes de caracterizacao focados. - -## Non-Goals - -- Eliminar todo estado global. -- Adicionar `Freeze()`, `Seal()`, `CompleteConfiguration()` ou API semelhante. -- Remover ou tornar obsoletos `EntityMaps` e `TypeConventions`. -- Tornar membros publicos apenas para teste. -- Reabilitar paralelismo de testes. -- Transformar FluentMap em container de DI. -- Mudar profiles para usar mutation scope de `SqlMapper.SetTypeMap`. -- Alterar Dommel. - -## Compatibility Constraints - -- `FluentMapper.Initialize(...)` deve manter sua assinatura e comportamento aditivo atual. -- `AddMap(new Map())`, `AddMap()`, `AddProfile()`, conventions, naming policies e scanning permanecem publicos. -- Duplicidades continuam falhando pelas regras ja existentes. -- `EntityMaps` e `TypeConventions` continuam publicos nesta entrega. -- `FluentMapper.Reset(...)` continua interno e voltado a testes. -- `Dapper.Query()` continua usando o type map global default. -- `QueryMapped()` continua selecionando profile por operacao. - -## Proposed Contract - -Consumidores devem configurar FluentMap durante startup, validar a configuracao e iniciar queries somente depois disso: - -```csharp -FluentMapper.Initialize(config => -{ - config.AddMap(); - config.AddProfile(); - config.UseNamingPolicy(NamingPolicy.SnakeCase).ForEntity(); -}); - -FluentMapper.Validate(); -``` - -Depois que queries comecarem, a configuracao deve ser tratada como read-only. - -Quando uma aplicacao precisar alterar configuracao em runtime usando APIs existentes, ela deve serializar externamente essa transicao e garantir que nao ha queries concorrentes usando os tipos afetados. Esse uso existe por compatibilidade, mas nao e o modelo recomendado nem contrato de concorrencia. - -Para SQL shapes alternativos da mesma entidade, o caminho suportado e profile query-scoped por `QueryMapped()`, nao troca temporaria de `SqlMapper.SetTypeMap`. - -## Alternatives Considered - -### A - Documentation Contract Only - -Aceita para esta entrega. - -Motivos: - -- menor risco de quebra; -- condiz com o historico de API publica mutavel; -- permite formalizar o contrato antes de encapsular estado; -- suficiente para preparar a Entrega 02. - -### B - Soft Enforcement - -Adiada. - -Possibilidades futuras: - -- diagnostics adicionais; -- API preferencial que exponha views read-only; -- avisos de documentacao XML; -- mecanismos internos de snapshot sem quebrar a fachada publica. - -Motivo para adiar: ainda nao ha modelo de detecao confiavel de "primeira query" sem acoplar o core aos detalhes de uso do Dapper e `QueryMapped*`. - -### C - Runtime Enforcement - -Rejeitada nesta entrega. - -Motivos: - -- exigiria saber quando a aplicacao entrou na fase operacional; -- quebraria chamadas repetidas de `Initialize(...)` que hoje funcionam para configuracao aditiva; -- conflitaria com dicionarios publicos mutaveis preservados por compatibilidade; -- exigiria estrategia de versao/migracao para consumidores. - -## Acceptance Criteria - -- A estrutura `docs/sdd/etapa-6/` existe. -- O README da etapa lista as quatro entregas e status. -- O estado atual de APIs mutadoras, static/global state, `SetTypeMap`, cache, profiles, reset, paralelismo e ausencia de freeze esta documentado. -- A decisao de enforcement esta registrada em `decisions.md`. -- O README publico documenta o lifecycle de configuracao. -- Testes caracterizam `Initialize(...)` repetido aditivo. -- Testes caracterizam mutacao runtime compatibilizada sob acesso serializado. -- Testes caracterizam que mutacao direta de dicionario publico nao e caminho deterministico de configuracao porque bypassa instalacao de type map. -- `docs/sdd/fluentmap-risk-assessment.md` foi revisado para FM-RISK-001 sem marcar o risco como resolvido. -- Validacao obrigatoria foi executada: restore, build, tests e pack. -- `handoff.md` contem contexto suficiente para a Entrega 02. - -## Risks / Residual Risks - -- FM-RISK-001 permanece mitigado, nao resolvido: estado global e `SqlMapper.SetTypeMap` continuam existindo. -- FM-RISK-002 permanece aberto: dicionarios publicos mutaveis podem bypassar registry, validacao e cache. -- O contrato depende de disciplina do consumidor durante a fase operacional. -- A suite continua com paralelismo desabilitado. -- Entrega 02 nao deve assumir que os dicionarios publicos podem ser removidos em minor version. - -## Validation Results - -Environment: - -- SDK: `10.0.302` -- test runner detected: VSTest with xUnit v3 -- core target: `netstandard2.0` -- test target: `net10.0` - -Localized validation: - -```text -dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --filter "FullyQualifiedName~ConfigurationLifecycleTests" -``` - -Result: - -- success; -- 3 tests passed. - -Mandatory validation: - -```text -dotnet restore .\Dapper.FluentMap.sln -dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore -dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build -dotnet pack .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release --no-build --output .\artifacts\packages -``` - -Results: - -- restore: success; -- build: success, 0 warnings, 0 errors; -- tests: success, 215 total tests passed: - - core: 184; - - Dommel: 7; - - analyzers: 9; - - generators: 14; - - generated-registration integration: 1; -- pack: `Dapper.FluentMap.2.0.0.nupkg` created successfully. - -Known pack warnings: - -- `NU5125` for legacy `PackageLicenseUrl`; -- NuGet README recommendation. - -These warnings are pre-existing package metadata debt tracked outside this delivery. diff --git a/docs/sdd/etapa-6/02-mapping-state-encapsulation.md b/docs/sdd/etapa-6/02-mapping-state-encapsulation.md deleted file mode 100644 index 4437d57..0000000 --- a/docs/sdd/etapa-6/02-mapping-state-encapsulation.md +++ /dev/null @@ -1,265 +0,0 @@ -# 02 - Mapping State Encapsulation - -## Current Exposure - -`FluentMapper.EntityMaps` exposes: - -```csharp -public static readonly ConcurrentDictionary EntityMaps -``` - -`FluentMapper.TypeConventions` exposes: - -```csharp -public static readonly ConcurrentDictionary> TypeConventions -``` - -Both fields are `readonly` only at the field-reference level. Consumers cannot assign a different dictionary to the field, but they can mutate the exposed `ConcurrentDictionary` instance and the mutable `IList` values. - -Available mutation operations include `TryAdd`, index assignment, `Remove`, `Clear` and explicit interface `Add` through `IDictionary` / `ICollection>`. - -Delivery 01 is confirmed as `COMPLETED` in `docs/sdd/etapa-6/README.md`. Its lifecycle decision is authoritative for this delivery: - -```text -Configuration Phase - | - v -Operational Phase -``` - -Configuration after the operational phase remains compatibility-only and requires external quiescence. Direct dictionary mutation is legacy compatibility debt, not a supported deterministic configuration path. - -## Mutation Paths - -| Mutation path | Validates | Invalidates cache | Updates Dapper | Supported | -| ------------- | --------- | ----------------- | -------------- | --------- | -| `Initialize(c => c.AddMap(map))` | Yes | Yes | Yes | Yes, during configuration phase | -| `Initialize(c => c.AddMap())` | Yes | Yes | Yes | Yes, during configuration phase | -| `Initialize(c => c.AddMapsFromAssembly(...))` | Yes | Yes | Yes | Yes, trimming-sensitive | -| `Initialize(c => c.AddProfile())` | Yes | Yes | No | Yes, query-scoped profiles only | -| `Initialize(c => c.AddConvention().ForEntity())` | Yes | Yes | Yes | Yes, during configuration phase | -| `Initialize(c => c.UseNamingPolicy(...).ForEntity())` | Yes | Yes | Yes | Yes, during configuration phase | -| `((IDictionary)FluentMapper.EntityMaps).Add(...)` | No | No | No | Legacy compatibility only | -| `FluentMapper.EntityMaps.TryAdd(...)` | No | No | No | Legacy compatibility only | -| `FluentMapper.EntityMaps[type] = map` | No | No | No | Legacy compatibility only | -| `FluentMapper.EntityMaps.Remove(...)` | No | No | No | Legacy compatibility only | -| `FluentMapper.EntityMaps.Clear()` | No | No | No | Legacy compatibility only | -| `((IDictionary>)FluentMapper.TypeConventions).Add(...)` | No | No | No | Legacy compatibility only | -| `FluentMapper.TypeConventions.TryAdd(...)` | No | No | No | Legacy compatibility only | -| `FluentMapper.TypeConventions[type] = list` | No | No | No | Legacy compatibility only | -| `FluentMapper.TypeConventions[type].Add(...)` | No | No | No | Legacy compatibility only | -| `FluentMapper.TypeConventions.Remove(...)` | No | No | No | Legacy compatibility only | -| `FluentMapper.TypeConventions.Clear()` | No | No | No | Legacy compatibility only | -| `FluentMapper.Reset(...)` | No public validation | Clears all caches | Removes requested type maps | Internal test isolation only | - -## Problem - -`MappingRegistry` is the intended mutation boundary. It validates entity maps, validates conventions, invalidates property-map/materialization-plan caches, and installs the default Dapper type map through `SqlMapper.SetTypeMap`. - -The public dictionaries expose the registry storage directly. As a result, consumers can add, replace or remove maps and conventions without the registry observing the mutation. This can produce stale cache entries, missing Dapper type-map installation, or diagnostics that disagree with query behavior. - -## Compatibility Constraints - -- The public fields cannot be removed in this delivery without source and binary breakage. -- Changing their declared type from `ConcurrentDictionary<...>` to a read-only interface would be source and binary breaking. -- Replacing the instances with non-mutable wrappers is impossible without changing the field type. -- Marking the fields with `[Obsolete]` is source-compatible and binary-compatible, but can break consumers that treat warnings as errors. -- Blocking runtime mutation would contradict Delivery 01 unless a major-version migration is planned. -- `Dommel` currently reads these fields directly; this delivery must not redesign Dommel. - -Compatibility impact matrix: - -| Possible change | Source breaking | Binary breaking | Behavior breaking | Decision | -| --------------- | --------------- | --------------- | ----------------- | -------- | -| Remove public fields | Yes | Yes | Yes | Rejected | -| Change field types to read-only interfaces | Yes | Yes | Yes | Rejected | -| Replace fields with read-only properties of same names | Yes | Yes | Yes | Rejected | -| Keep fields and add read-only APIs | No | No | No | Accepted | -| Mark fields `[Obsolete]` | Warning-only, but can fail warnings-as-errors builds | No | No | Deferred | -| Throw on runtime mutation through official APIs | No | No | Yes | Rejected for this delivery | -| Detect all external direct mutations | No reliable path with current field types | No reliable path with current field types | Could be partial/inconsistent | Rejected | - -## Goals - -- Provide official read-only accessors for mapping state inspection. -- Keep new read-only access snapshot-based so consumers cannot mutate registry collections through the new API. -- Keep all official mutations conceptually behind `FluentMapper -> MappingRegistry`. -- Preserve existing public fields for source and binary compatibility. -- Document direct dictionary mutation as a legacy compatibility surface. -- Preserve lifecycle, precedence, profiles, naming policies, inherited maps, cache invalidation and Dapper integration. - -## Non-Goals - -- Remove `EntityMaps` or `TypeConventions`. -- Change the declared type of existing public fields. -- Add runtime freezing/sealing. -- Detect every possible direct mutation of legacy dictionaries. -- Make `IEntityMap`, `Convention` or `PropertyMap` immutable. -- Redesign Dommel. -- Implement a generated materializer. - -## Proposed Encapsulation Strategy - -Add snapshot-based read-only APIs: - -```csharp -FluentMapper.GetEntityMaps() -FluentMapper.GetTypeConventions() -``` - -These APIs return read-only snapshots of the current default entity maps and type conventions. The snapshots do not expose `ConcurrentDictionary` or mutable convention lists. They are intended for diagnostics, inspection and migration away from direct dictionary reads. - -The existing public fields remain as legacy compatibility surface. Their XML documentation is updated to tell consumers to use fluent registration APIs for mutation and read-only snapshots for inspection. - -The registry remains the mutation owner for official APIs: - -```text -Consumer API - | - v -FluentMapper / FluentMapConfiguration - | - v -MappingRegistry - | - v -Validation - | - v -Cache invalidation - | - v -Dapper integration -``` - -No cache invalidation or validation logic is duplicated outside `MappingRegistry`. - -## Migration Strategy - -Minor-compatible migration: - -- New code should use `Initialize(...)`, `AddMap(...)`, `AddProfile(...)`, convention APIs and naming policies for mutation. -- New code that only needs to inspect mappings should use `GetEntityMaps()` and `GetTypeConventions()`. -- Existing code that mutates `EntityMaps` or `TypeConventions` continues to compile and run, but remains legacy and can bypass invariants. - -Future major-version migration: - -- Replace public mutable fields with read-only properties. -- Move mutable state behind registry-owned methods only. -- Consider immutable snapshots for effective mapping state after configuration. -- Consider an explicit compatibility adapter for Dommel instead of direct dictionary reads. - -## Public API Impact - -Added public APIs: - -```csharp -public static IReadOnlyDictionary GetEntityMaps() -public static IReadOnlyDictionary> GetTypeConventions() -``` - -Preserved public APIs: - -```csharp -public static readonly ConcurrentDictionary EntityMaps -public static readonly ConcurrentDictionary> TypeConventions -``` - -The legacy fields are not marked `[Obsolete]` in this delivery. The reason is compatibility risk for consumers that compile with warnings as errors. - -## Internal API Impact - -`MappingRegistry` adds snapshot builders for entity maps and type conventions. They copy the dictionary contents and copy convention lists into read-only collections. - -No registry mutation rule is moved out of `MappingRegistry`. - -## Implementation - -Implemented: - -- `FluentMapper.GetEntityMaps()`; -- `FluentMapper.GetTypeConventions()`; -- `MappingRegistry.GetEntityMapsSnapshot()`; -- `MappingRegistry.GetTypeConventionsSnapshot()`; -- XML documentation remarks on `EntityMaps` and `TypeConventions`; -- README guidance to prefer snapshots for read-only inspection. - -Not implemented: - -- `[Obsolete]` attributes on legacy fields; -- freeze/seal lifecycle enforcement; -- automatic direct-mutation detection; -- immutable effective mapping state; -- Dommel redesign. - -## Acceptance Criteria - -- Delivery 02 SDD document exists. -- Stage README marks Delivery 02 progress and final completion. -- `EntityMaps` and `TypeConventions` public signatures are documented. -- Mutation paths and bypass behavior are documented. -- Official read-only snapshot APIs are implemented and tested. -- Official map/convention registration still validates, invalidates cache and updates Dapper as before. -- Profile behavior remains query-scoped and does not update Dapper type maps. -- Legacy direct mutation remains possible but is characterized as bypassing registry invariants. -- FM-RISK-001 and FM-RISK-002 are reviewed. -- Restore, build, tests and pack are executed and recorded. -- A single semantic commit is created. - -## Residual Risk - -Direct mutation remains possible through `EntityMaps`, `TypeConventions`, mutable `IEntityMap.PropertyMaps`, mutable `Convention.PropertyMaps` and mutable `Convention.ConventionConfigurations`. Because the public field signatures expose concrete mutable collections, full prevention requires a major-version compatibility break. - -The new read-only APIs reduce risk for inspection and migration, but they do not make the legacy surface safe. - -## Validation Results - -Environment: - -- SDK: `10.0.302` -- test runner detected: VSTest with xUnit v3 -- core target: `netstandard2.0` -- test target: `net10.0` - -Localized validation: - -```text -dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --filter "FullyQualifiedName~MappingStateEncapsulationTests" -``` - -Result: - -- success; -- 6 tests passed. - -Mandatory validation: - -```text -dotnet restore .\Dapper.FluentMap.sln -dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore -dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build -dotnet pack .\Dapper.FluentMap.sln --configuration Release --no-build --output .\artifacts\packages -``` - -Results: - -- restore: success; -- build: success, 0 warnings, 0 errors; -- tests: success, 221 total tests passed: - - core: 190; - - Dommel: 7; - - analyzers: 9; - - generators: 14; - - generated-registration integration: 1; -- pack: success: - - `Dapper.FluentMap.2.0.0.nupkg`; - - `Dapper.FluentMap.Dommel.2.0.0.nupkg`; - - `Dapper.FluentMap.Analyzers.2.0.0.nupkg`; - - `Dapper.FluentMap.Generators.2.0.0.nupkg`. - -Known pack warnings: - -- `NU5125` for legacy `PackageLicenseUrl` in core and Dommel; -- NuGet README recommendation for core and Dommel. - -These warnings are pre-existing package metadata debt tracked outside this delivery. diff --git a/docs/sdd/etapa-6/03-dapper-compatibility-adapters.md b/docs/sdd/etapa-6/03-dapper-compatibility-adapters.md deleted file mode 100644 index 95d40a4..0000000 --- a/docs/sdd/etapa-6/03-dapper-compatibility-adapters.md +++ /dev/null @@ -1,277 +0,0 @@ -# 03 - Dapper Compatibility Adapters - -Status: COMPLETED - -## Specification - -Esta entrega isola os pontos em que o FluentMap dependia de detalhes frageis do Dapper, sem alterar o comportamento publico de mappings e sem reimplementar o Dapper. - -Os riscos tratados sao: - -- `FM-RISK-007`: acesso reflexivo a `SqlMapper.TypeHandlerCache.Parse`; -- `FM-RISK-012`: sentinel `IgnoredPropertyInfo` com membros que lancavam `NotImplementedException`. - -## Current Dapper Integration Points - -O core ainda integra com Dapper por superficies publicas: - -- `SqlMapper.SetTypeMap(type, typeMap)` para instalar o type map default por entidade; -- `SqlMapper.GetTypeMap(type)` em testes e consumidores que inspecionam o estado do Dapper; -- `SqlMapper.ITypeMap` para constructor mapping, member mapping e fallback; -- `SqlMapper.IMemberMap` para expor property/field/parameter ao materializer do Dapper; -- `SqlMapper.HasTypeHandler(type)` para detectar handler registrado; -- `SqlMapper.TypeHandler` para handlers de consumidores. - -A versao fixada no core permanece: - -```text -Dapper 2.1.79 -``` - -Na versao atual nao foi encontrada API publica do Dapper que converta um `object` usando o TypeHandler registrado para um tipo arbitrario. As APIs publicas relacionadas sao registro/reset de handlers, `HasTypeHandler`, type maps, row parsers e parsers baseados em `IDataReader`. - -## Version-Sensitive Areas - -As areas sensiveis a upgrade de Dapper sao: - -- assinatura e comportamento de `SqlMapper.ITypeMap`; -- assinatura e comportamento de `SqlMapper.IMemberMap`; -- comportamento de fallback quando um mapper retorna `null`; -- constructor mapping delegado a `DefaultTypeMap`; -- existencia do nested type `SqlMapper.TypeHandlerCache`; -- existencia do metodo publico static `TypeHandlerCache.Parse(object)`; -- semantica de `SqlMapper.SetTypeMap`, que continua global por processo. - -## TypeHandler Problem - -`QueryMapped*` controla seu proprio loop de `DbDataReader`, portanto nao passa pelo materializer interno do Dapper. Para preservar Value Objects escalares, o materializer precisa respeitar handlers registrados com Dapper. - -O caminho anterior fazia isso dentro de `NestedMaterializationPlan`: - -```text -SqlMapper.HasTypeHandler -typeof(SqlMapper).GetNestedType("TypeHandlerCache`1") -MakeGenericType -GetMethod("Parse") -Expression.Call(Parse) -``` - -Esse acoplamento estava concentrado em uma funcao, mas ainda fazia parte do materializer e podia falhar silenciosamente retornando ao conversor padrao quando a shape interna do Dapper mudasse. - -## Ignored Member Problem - -O caminho anterior usava `IgnoredPropertyInfo : PropertyInfo` para bloquear fallback do Dapper em duas situacoes: - -- propriedade explicitamente ignorada; -- path nested que nao deve ser tratado como propriedade simples pelo `Dapper.Query`. - -O sentinel existia porque `CustomPropertyTypeMap` aceita apenas uma funcao que retorna `PropertyInfo`. Para impedir fallback, era necessario retornar algo nao nulo que o `MultiTypeMap` pudesse reconhecer. - -O problema era a fragilidade: quase todos os membros de `IgnoredPropertyInfo` lancavam `NotImplementedException`. Se Dapper ou outro mapper inspecionasse o `PropertyInfo` antes da interceptacao do FluentMap, a falha escaparia de forma pouco diagnosticavel. - -## Goals - -- Criar uma fronteira interna explicita para detalhes de compatibilidade com Dapper. -- Centralizar reflection residual para TypeHandlers. -- Falhar com diagnostico claro se a shape interna esperada do Dapper deixar de existir. -- Remover o sentinel `PropertyInfo` com `NotImplementedException`. -- Preservar precedencia efetiva: explicito, convention/naming policy, fallback do Dapper. -- Preservar `Dapper.Query` para mappings simples e `QueryMapped*` para materializacao controlada. -- Cobrir os caminhos com testes direcionados. - -## Non-Goals - -- Atualizar Dapper. -- Copiar internals do Dapper para o FluentMap. -- Criar interfaces publicas. -- Substituir `SqlMapper.SetTypeMap`. -- Fazer profiles funcionarem em `Dapper.Query`. -- Implementar materializer gerado. -- Alterar Dommel. - -## Compatibility Boundary - -A fronteira escolhida fica no namespace interno `Dapper.FluentMap.Compatibility`: - -```text -FluentMap materialization/type maps - | - v -internal Dapper compatibility boundary - | - v -Dapper-specific behavior -``` - -Componentes adicionados: - -- `DapperTypeHandlerAdapter`: unico ponto de reflection para `SqlMapper.TypeHandlerCache.Parse(object)`; -- `DapperFluentPropertyTypeMap`: `ITypeMap` interno que resolve `IPropertyMap` sem passar por `CustomPropertyTypeMap`; -- `DapperPropertyMemberMap`: `IMemberMap` seguro para propriedades simples; -- `DapperIgnoredMemberMap`: `IMemberMap` seguro para ignored/nested e reconhecido pelo `MultiTypeMap`. - -## Proposed Design - -### TypeHandler - -`NestedMaterializationPlan` passa a delegar a decisao e a construcao do conversor para `DapperTypeHandlerAdapter`. - -O adapter: - -- usa `SqlMapper.HasTypeHandler` como superficie publica de deteccao; -- usa reflection residual apenas para localizar `TypeHandlerCache.Parse(object)`; -- considera `Nullable` separando tipo declarado e tipo do handler; -- retorna `null` para `DBNull` quando o destino declarado aceita null; -- lanca `FluentMapConfigurationException` quando a shape esperada do Dapper nao existe. - -### Ignored Member - -`FluentMapTypeMap`, `FluentConventionTypeMap` e o type map interno nao usam mais `CustomPropertyTypeMap` para propriedades FluentMap. Eles usam `DapperFluentPropertyTypeMap`, que pode retornar diretamente um `IMemberMap`. - -Quando um mapping e ignored ou nested: - -```text -DapperFluentPropertyTypeMap.GetMember(column) - -> DapperIgnoredMemberMap - -> MultiTypeMap reconhece o marker - -> retorna null sem consultar DefaultTypeMap -``` - -Assim o fallback do Dapper continua bloqueado, mas nenhum `PropertyInfo` falso ou lancador e exposto. - -## Alternatives Rejected - -### Keep IgnoredPropertyInfo - -Rejeitada. Preservaria comportamento, mas manteria o risco de `NotImplementedException` se o sentinel fosse inspecionado. - -### Return null for ignored/nested directly - -Rejeitada. Isso permitiria que `MultiTypeMap` continuasse para `DefaultTypeMap`, fazendo propriedades ignoradas ou paths nested com mesmo nome de coluna serem materializados pelo Dapper. - -### Copy Dapper TypeHandler internals - -Rejeitada. A entrega e sobre boundary de compatibilidade, nao fork ou copia de implementacao. - -### Upgrade Dapper - -Rejeitada nesta entrega. Nao ha specification de dependency upgrade, e a versao `2.1.79` permanece a referencia validada. - -## Failure Behavior - -Se o TypeHandler estiver registrado, mas o adapter nao conseguir resolver `SqlMapper.TypeHandlerCache.Parse(object)`, o FluentMap deve lancar `FluentMapConfigurationException` com: - -- tipo de destino; -- mencao explicita ao boundary de TypeHandler; -- orientacao para revisar compatibilidade antes de atualizar Dapper. - -Falha diagnosticavel foi escolhida em vez de fallback silencioso, porque fallback para `Convert.ChangeType` pode materializar valor errado ou mascarar uma quebra de upgrade. - -## Acceptance Criteria - -- `DapperTypeHandlerAdapter` centraliza reflection para TypeHandlers. -- `NestedMaterializationPlan` nao chama `GetNestedType`, `MakeGenericType` ou `GetMethod` para TypeHandler. -- TypeHandler registrado e usado por `QueryMapped*`. -- `Nullable` com handler registrado preserva `null` para `DBNull`. -- Sem handler, conversao padrao existente continua funcionando. -- Falha de shape interna do Dapper e diagnosticavel. -- `IgnoredPropertyInfo` e removido. -- Ignored root property bloqueia fallback do Dapper. -- Ignored nested path bloqueia fallback para propriedade raiz homonima. -- Fallback default do Dapper continua funcionando para colunas nao configuradas. -- Testes de Etapa 5 continuam passando. - -## Residual Risks - -- `FM-RISK-007` permanece `MITIGATED`, nao `RESOLVED`: ainda existe reflection para `SqlMapper.TypeHandlerCache.Parse(object)`, mas ela esta isolada e coberta por testes. -- `SqlMapper.SetTypeMap` permanece estado global do Dapper. -- `QueryMapped*` continua runtime/reflection/dynamic-code based. -- Upgrades futuros de Dapper ainda exigem checklist especifico para `ITypeMap`, `IMemberMap`, constructor mapping e TypeHandlers. - -## Implementation - -Arquivos adicionados: - -- `src/Dapper.FluentMap/Compatibility/DapperTypeHandlerAdapter.cs`; -- `src/Dapper.FluentMap/Compatibility/DapperFluentPropertyTypeMap.cs`; -- `src/Dapper.FluentMap/Compatibility/DapperPropertyMemberMap.cs`; -- `src/Dapper.FluentMap/Compatibility/DapperIgnoredMemberMap.cs`; -- `test/Dapper.FluentMap.Tests/DapperCompatibilityAdapterTests.cs`. - -Arquivos alterados: - -- `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs`; -- `src/Dapper.FluentMap/TypeMaps/FluentTypeMap.cs`; -- `src/Dapper.FluentMap/TypeMaps/FluentMapTypeMap.cs`; -- `src/Dapper.FluentMap/TypeMaps/FluentConventionTypeMap.cs`; -- `src/Dapper.FluentMap/TypeMaps/MultiTypeMap.cs`; -- `src/Dapper.FluentMap/MappingRegistry.cs`. - -Arquivo removido: - -- `src/Dapper.FluentMap/TypeMaps/IgnoredPropertyInfo.cs`. - -## Validation Results - -Environment: - -- SDK: `10.0.302` -- test runner detected: VSTest with xUnit v3 -- core target: `netstandard2.0` -- test target: `net10.0` -- Dapper: `2.1.79` - -Localized validation: - -```text -dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Debug --filter "FullyQualifiedName~DapperCompatibilityAdapterTests" -``` - -Result: - -- success; -- 8 tests passed. - -Related validation: - -```text -dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Debug --filter "FullyQualifiedName~DapperCompatibilityAdapterTests|FullyQualifiedName~ValueObjectMaterializationTests|FullyQualifiedName~NestedMaterializationSpikeTests|FullyQualifiedName~NestedObjectMaterializationTests|FullyQualifiedName~ConstructorMappingTests|FullyQualifiedName~DapperIntegrationTests|FullyQualifiedName~MappingProfileTests" -dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --no-build --filter "FullyQualifiedName~DapperCompatibilityAdapterTests|FullyQualifiedName~ValueObjectMaterializationTests|FullyQualifiedName~NestedMaterializationSpikeTests|FullyQualifiedName~NestedObjectMaterializationTests|FullyQualifiedName~ConstructorMappingTests|FullyQualifiedName~DapperIntegrationTests|FullyQualifiedName~MappingProfileTests" -``` - -Results: - -- Debug related tests: success, 79 tests passed; -- Release related tests: success, 79 tests passed. - -Mandatory validation: - -```text -dotnet restore .\Dapper.FluentMap.sln -dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore -dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build -dotnet pack .\Dapper.FluentMap.sln --configuration Release --no-build --output .\artifacts\packages -``` - -Results: - -- restore: success; -- build: success, 0 warnings, 0 errors; -- tests: success, 229 total tests passed: - - core: 198; - - Dommel: 7; - - analyzers: 9; - - generators: 14; - - generated-registration integration: 1; -- pack: success: - - `Dapper.FluentMap.2.0.0.nupkg`; - - `Dapper.FluentMap.Dommel.2.0.0.nupkg`; - - `Dapper.FluentMap.Analyzers.2.0.0.nupkg`; - - `Dapper.FluentMap.Generators.2.0.0.nupkg`. - -Known pack warnings: - -- `NU5125` for legacy `PackageLicenseUrl` in core and Dommel; -- NuGet README recommendation for core and Dommel. - -These warnings are pre-existing package metadata debt tracked outside this delivery. diff --git a/docs/sdd/etapa-6/04-generated-materializer-spike.md b/docs/sdd/etapa-6/04-generated-materializer-spike.md deleted file mode 100644 index aa34ac4..0000000 --- a/docs/sdd/etapa-6/04-generated-materializer-spike.md +++ /dev/null @@ -1,570 +0,0 @@ -# 04 - Generated Materializer Spike - -Status: COMPLETED - -## Current Architecture - -`QueryMapped*` e o caminho opt-in atual para materializacao controlada pelo FluentMap. Ele executa o comando pelo Dapper, abre um `IDataReader`, coleta os nomes das colunas e pede ao `MappingRegistry` um `NestedMaterializationPlan` cacheado por: - -```text -EntityType + ProfileType + ordered column names -``` - -O plano runtime: - -- resolve mappings efetivos por coluna, incluindo profile opcional; -- preserva `MemberPath` completo para paths como `Rank.Level` e `Seniority.Level`; -- aplica precedencia de mapping explicito, convention/naming policy e fallback default do Dapper; -- constroi objetos aninhados mutaveis por construtor publico sem parametros e setters publicos; -- constroi Value Objects e objetos imutaveis por construtores publicos compativeis; -- decide `DBNull`/null por subarvore; -- usa `DapperTypeHandlerAdapter` para TypeHandlers escalares; -- compila delegates com `Expression.Compile`. - -As APIs publicas `QueryMapped*` permanecem anotadas com: - -```text -RequiresUnreferencedCode -RequiresDynamicCode -``` - -O source generator atual (`Dapper.FluentMap.Generators`) gera apenas registro: - -```csharp -configuration.AddGeneratedMappings(); -``` - -Ele descobre maps na compilacao atual e emite `AddMap()` ou `AddProfile()`. Ele nao le `DbDataReader`, nao interpreta todo o corpo do map e nao gera materializers. - -## Problem - -`FM-RISK-004` permanece: `QueryMapped*` depende de reflection runtime e dynamic code para gerar accessors, factories, conversores e chamadas de construtor. Isso limita uso em trimming/Native AOT e cria custo de primeira query por plano. - -O spike investiga se e tecnicamente viavel gerar materializers de `DbDataReader` em compile-time para o subconjunto de mappings do FluentMap que pode ser conhecido estaticamente, preservando fallback runtime para configuracao dinamica. - -## Research Questions - -1. Qual metadata o source generator consegue obter em compile-time? -2. Quais mappings sao estaticos e detectaveis? -3. Quais mappings podem ser construidos dinamicamente e portanto nao sao geraveis? -4. Como profiles poderiam ser representados? -5. Como `MemberPath` poderia virar codigo gerado? -6. Como nested mutable objects seriam materializados? -7. Como immutable Value Objects seriam materializados? -8. Como constructors seriam selecionados? -9. Como TypeHandlers seriam integrados? -10. Como `DBNull`/null seriam tratados? -11. Como conversoes seriam feitas? -12. Como naming policies/conventions afetariam geracao? -13. Como mappings registrados em assemblies externos seriam tratados? -14. Como caching mudaria? -15. Como generated e runtime materializer coexistiriam? - -## Experiments Performed - -Arquivos analisados: - -- `docs/sdd/etapa-4/02-trimming-aot.md`; -- `docs/sdd/etapa-4/03-source-generator.md`; -- `docs/sdd/etapa-5/01-nested-materialization-spike.md`; -- `docs/sdd/etapa-5/02-nested-object-materialization.md`; -- `docs/sdd/etapa-5/03-value-objects.md`; -- `docs/sdd/etapa-5/04-mapping-profiles.md`; -- `docs/sdd/etapa-6/01-configuration-lifecycle.md`; -- `docs/sdd/etapa-6/02-mapping-state-encapsulation.md`; -- `docs/sdd/etapa-6/03-dapper-compatibility-adapters.md`; -- `docs/sdd/fluentmap-risk-assessment.md`; -- `src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs`; -- `src/Dapper.FluentMap/QueryMappedExtensions.cs`; -- `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs`; -- `src/Dapper.FluentMap/MappingRegistry.cs`; -- `src/Dapper.FluentMap/Mapping/MemberPath.cs`; -- tests de generator, profiles, nested materialization, Value Objects e TypeHandler compatibility. - -Prototipo adicionado: - -- `test/Dapper.FluentMap.Tests/GeneratedMaterializerSpikeTests.cs`. - -O prototipo e intencionalmente test-only. Ele simula codigo que um generator poderia emitir: - -- usa ordinais fixos de `IDataRecord`; -- nao usa `Expression.Compile`; -- nao usa reflection para getter, setter ou construtor; -- materializa uma entidade simples com mapping explicito; -- materializa nested mutable object por subarvore; -- materializa Value Object imutavel por construtor; -- representa profile por metodo gerado separado; -- preserva `DBNull` como `null` em reference/Value Object nullable. - -## Prototype - -Forma conceitual validada pelo teste: - -```csharp -internal static GeneratedCustomer ReadLegacyProfile(IDataRecord record) -{ - return new GeneratedCustomer( - ReadInt32(record, 0), - record.IsDBNull(1) ? null : new GeneratedCpf(ReadString(record, 1)), - ReadString(record, 2)); -} -``` - -Esse codigo prova que, quando coluna, path, construtor e profile sao conhecidos, o materializer pode ser codigo direto contra `IDataRecord`/`DbDataReader`, sem a combinacao atual de reflection + expression compilation no runtime. - -O prototipo nao prova: - -- discovery automatica de todas as chamadas fluent no corpo de mapas; -- TypeHandler gerado; -- Native AOT runtime real; -- convencoes complexas; -- performance. - -## Findings - -### 1. Metadata disponivel em compile-time - -O generator atual ja consegue obter por Roslyn: - -- classes de map na compilacao atual; -- `IEntityMap`; -- `IProfileMap`; -- abstracao, genericidade, visibilidade e construtor publico sem parametros do map; -- hierarquia de tipos e symbols de entidades/propriedades/construtores quando referenciados no codigo fonte. - -Para materializer, o generator poderia obter mais metadata apenas se interpretar um subconjunto estatico da DSL: - -- chamadas `Map(x => x.Property)` e `Map(x => x.Nested.Property)`; -- chamadas `ToColumn("literal")`; -- chamadas `Ignore()`; -- `IncludeBase()`; -- `IProfileMap`. - -Ele nao deve executar o construtor do map. Construtores de maps sao codigo arbitrario. - -### 2. Mappings estaticos e detectaveis - -Geraveis com boa confianca: - -- maps declarados na compilacao atual; -- lambdas simples de member access; -- coluna literal em `ToColumn`; -- `Ignore`; -- profile por `IProfileMap`; -- `IncludeBase` quando base map geravel no mesmo contexto; -- constructor binding por nomes de propriedades/parametros visiveis no symbol model. - -### 3. Mappings dinamicos nao geraveis - -Nao geraveis sem fallback: - -- column names calculados por variavel, helper, config externa ou interpolacao nao constante; -- chamadas fluent escondidas em metodos arbitrarios; -- maps adicionados por `AddMap(new SomeMap(runtimeValue))`; -- assembly scanning; -- mutacao direta de `FluentMapper.EntityMaps` e `TypeConventions`; -- conventions customizadas que executam codigo no construtor; -- naming policies aplicadas dinamicamente; -- maps em assemblies referenciados sem um contrato de manifesto gerado; -- qualquer path que dependa de reflection runtime nao representada na compilacao atual. - -### 4. Profiles - -Profiles devem virar chaves geradas fortemente tipadas: - -```text -EntityType + ProfileType + ColumnShape -``` - -Cada profile geravel pode produzir um materializer separado ou um descriptor gerado separado. Isso preserva a decisao E6-D003: profile e query-scoped e nao troca `SqlMapper.SetTypeMap`. - -### 5. MemberPath como codigo gerado - -`MemberPath` pode virar uma cadeia de symbols no codigo gerado: - -```text -Customer.Cpf.Number -> constructor arg Cpf(number) -Customer.Address.City -> ensure Address then set City -``` - -Para o runtime gerado, a identidade precisa continuar sendo o path completo, nao apenas o terminal. Isso evita colisao entre `Rank.Level` e `Seniority.Level`. - -### 6. Nested mutable objects - -Codigo gerado pode emitir: - -```text -if any subtree column is non-null: - if parent.Address == null: - parent.Address = new Address() - parent.Address.City = value -else: - parent.Address = null when assignable -``` - -Isso e equivalente a semantica atual de subarvore e nao exige reflection se os setters/construtores forem publicos e conhecidos. - -### 7. Immutable Value Objects - -Codigo gerado pode emitir construcao bottom-up: - -```text -Cpf cpf = all cpf subtree columns are null ? null : new Cpf(number) -Customer customer = new Customer(id, cpf) -``` - -Construtores privados, setters privados, fields e `FormatterServices` continuam fora do contrato. Factory methods poderiam ser geradas futuramente apenas com API explicita. - -### 8. Constructor selection - -O generator pode usar symbols para selecionar construtores publicos por nome de parametro e tipo compativel, espelhando a regra atual. A parte sensivel e que o shape real de colunas vem do reader em runtime. Portanto a selecao gerada deve ser condicionada ao materializer gerado para aquele profile/map e ao conjunto de colunas suportado, com fallback se a query nao trouxer colunas esperadas. - -### 9. TypeHandlers - -Ha duas opcoes: - -- chamar diretamente um caminho Dapper generico conhecido para `T`, quando possivel; -- criar uma pequena API publica ou boundary geravel no core para converter via TypeHandler sem reflection por tipo arbitrario. - -O primeiro caminho reduz reflection, mas acopla codigo gerado a uma shape version-sensitive do Dapper. O segundo exige API publica nova e deve ser especificado antes de implementacao. A decisao da Entrega 03 permanece: nao espalhar reflection Dapper-specific. - -### 10. DBNull/null - -Codigo gerado deve preservar a regra atual: - -- `DBNull` em reference/nullable vira `null`; -- `DBNull` em value type nao anulavel vira default quando esse for o contrato atual; -- subarvore toda `NULL` vira objeto nested/value object `null`; -- subarvore parcialmente preenchida cria o objeto e passa `null`/default para folhas correspondentes. - -### 11. Conversoes - -Geravel: - -- typed getters quando o tipo da coluna for previsivel; -- `Convert.ToXxx`/`Convert.ChangeType` para fallback local; -- enum por string ou valor numerico; -- `Guid` por string; -- nullable wrappers. - -Ainda precisa de decisao: - -- cultura e provider; -- overflow/invalid cast diagnostics; -- TypeHandler sem reflection; -- conversoes customizadas publicas. - -### 12. Naming policies/conventions - -Mappings explicitos com colunas literais sao bons candidatos. - -Naming policies e conventions sao mais dificeis: - -- `NamingPolicy.SnakeCase` built-in pode ser geravel se registrado estaticamente; -- conventions customizadas sao objetos com codigo arbitrario e hoje populam `PropertyMaps` em runtime; -- per-profile conventions ainda nao existem. - -Recomendacao: primeira etapa gerada deve cobrir explicit maps e talvez naming policies built-in comprovaveis; conventions dinamicas devem usar fallback. - -### 13. Assemblies externos - -O generator atual descobre apenas maps da compilacao atual. Para assemblies externos ha opcoes: - -- cada assembly gera seu proprio manifesto/materializers e expõe um registro gerado local; -- o assembly consumidor referencia manifests de dependencies; -- fallback runtime para maps externos. - -O caminho mais compativel e permitir coexistencia: materializers gerados por assembly quando disponiveis, fallback runtime quando nao. - -### 14. Caching - -O cache runtime mudaria de plano unico para duas camadas: - -```text -GeneratedMaterializerRegistry - key: EntityType + ProfileType + ColumnShape - value: delegate/static descriptor gerado - -Runtime MaterializationPlanCache - key: EntityType + ProfileType + ColumnShape - value: NestedMaterializationPlan -``` - -O fallback runtime permanece essencial para dynamic maps. A invalidacao do registry deve remover qualquer cache runtime afetado, mas materializers gerados sao estaticos e so devem ser usados se a configuracao efetiva ainda corresponder ao descriptor gerado. - -### 15. Coexistencia generated/runtime - -Arquitetura recomendada: - -```text -QueryMapped - | - v -Resolve EntityType + ProfileType + ColumnShape - | - v -Generated materializer matches effective mapping? - | yes - v -Generated path - | - no - v -Runtime NestedMaterializationPlan fallback -``` - -O fallback deve ser transparente e diagnosticavel. Ele preserva compatibilidade com maps dinamicos e evita transformar o generator em requisito de runtime. - -## Architecture Comparison - -| Dimension | A. Runtime-only | B. Generated registration + runtime materializer | C. Generated materializer with runtime fallback | D. Fully generated-only path | -| --- | --- | --- | --- | --- | -| Compatibility | Alta; e a arquitetura atual | Alta; ja existe | Alta se fallback for padrao | Baixa; quebra maps dinamicos/scanning | -| AOT | Limitada por `QueryMapped*` anotado | Registro melhora, materializer nao | Melhor para casos gerados; fallback segue anotado | Melhor potencial, mas perde cobertura | -| Performance | Custo de plano/delegates na primeira query | Igual A para materializacao | Hipotese de menor first-query/hot path nos casos gerados | Melhor potencial, sem fallback | -| Dynamic maps | Suportados | Suportados | Suportados via fallback | Nao suportados | -| Profiles | Suportados runtime | Suportados runtime | Geraveis por `TProfile` + fallback | Apenas profiles gerados | -| Complexity | Media, ja paga | Media | Alta, mas incremental | Muito alta | -| Diagnostics | Runtime authoritative | Runtime authoritative | Precisa explicar generated vs fallback | Compile-time forte, runtime restrito | -| Maintenance | Concentrada no core | Core + generator registro | Core + generator + registry de materializers | Alto risco de dois mundos ou breaking changes | - -## AOT/Trimming Assessment - -### Proven - -- O runtime atual de `QueryMapped*` esta anotado com `RequiresUnreferencedCode` e `RequiresDynamicCode`. -- O generator atual nao gera materializers; ele gera registro e ja foi validado em smokes trimmed anteriores sem warnings FluentMap-owned no caminho gerado. -- A PoC test-only materializa simple/nested/value-object/profile/null sem `Expression.Compile`, sem reflection para members e sem `Activator` no hot path. -- Native AOT runtime completo nao foi validado neste ambiente nas etapas anteriores por ausencia do platform linker C++. - -### Likely - -- Um materializer gerado para mappings estaticos pode remover `Expression.Compile` do caminho gerado. -- Construtores publicos e setters publicos conhecidos podem ser chamados diretamente, reduzindo dependencia de reflection runtime. -- `MemberPath` gerado como cadeia de symbols reduz necessidade de preservar metadata de propriedades para o hot path. -- Fallback runtime ainda exigira manter as annotations atuais nas APIs que podem cair no caminho runtime. - -### Unknown - -- Se uma API publica AOT-safe para TypeHandlers arbitrarios pode ser oferecida sem depender de `SqlMapper.TypeHandlerCache.Parse`. -- Se todos os warnings dependency-owned do Dapper seriam removidos em um consumidor real. -- Runtime Native AOT real, porque nao ha validacao local com platform linker C++. -- Como representar conventions customizadas geradas sem executar codigo arbitrario. -- Como validar, no runtime, que a configuracao efetiva ainda corresponde ao materializer gerado quando public mutable dictionaries foram alterados diretamente. - -## Performance Hypotheses / Evidence - -Evidence: - -- A PoC elimina reflection/expression compilation no materializer test-only. -- O runtime atual cacheia planos, portanto o maior ganho esperado e em primeira query e hot path por row, nao em toda chamada igualmente. - -Hypothesis: - -- startup cost: pode aumentar ligeiramente por registrar manifests/materializers gerados; -- first query cost: deve cair para casos gerados porque nao ha criacao de `NestedMaterializationPlan` nem `Expression.Compile`; -- steady-state throughput: pode melhorar por chamadas diretas e menos indirection; -- allocation: pode reduzir objetos de plano/delegates e arrays de argumentos se construtores forem chamados diretamente; -- memory: pode trocar memoria runtime de cache por IL gerado no assembly consumidor. - -Nao ha benchmark formal nesta entrega. Nenhuma afirmacao de performance deve ser tratada como fato ate existir benchmark com Dapper default, `QueryMapped*` runtime e generated path. - -## Profile Implications - -Profiles combinam bem com geracao porque ja possuem identidade forte por `TProfile`. A geracao deve preservar: - -- `Dapper.Query()` usando somente default map; -- `QueryMapped()` selecionando profile por operacao; -- nenhum `SqlMapper.SetTypeMap` temporario; -- cache incluindo `ProfileType`; -- conventions/naming policies por entidade ate existir decisao de per-profile conventions. - -## Value Object Implications - -Geracao ajuda Value Objects por construtor porque o codigo pode ser bottom-up e direto: - -```text -leaf scalar values -> Value Object constructor -> root constructor/setter -``` - -Factory methods tambem poderiam ficar melhores em codigo gerado, mas somente se houver API publica explicita para selecionar a factory. A geracao e positiva para essa feature futura, desde que nao tente inferir factories por nome. - -## Streaming Implications - -Generated materializer facilita streaming porque separa: - -```text -reader lifecycle - from -row materialization delegate -``` - -Um futuro streaming/unbuffered path poderia iterar `DbDataReader.Read()` e chamar um materializer gerado por row sem armazenar tudo em `List`. - -Ainda assim, streaming exige uma entrega propria para: - -- ownership de connection/reader; -- enumeracao lazy sem reader ja disposto; -- async streaming; -- cancellation; -- disposal deterministico; -- comportamento em excecoes durante enumeracao. - -Este spike nao implementa streaming. - -## Runtime Fallback Strategy - -O fallback e obrigatorio. - -Regras recomendadas: - -- usar generated path apenas quando entity, profile e column shape forem reconhecidos; -- validar que a configuracao efetiva corresponde ao descriptor gerado; -- cair para runtime quando houver map dinamico, convention nao geravel, scanning, assembly externo sem manifest ou shape inesperado; -- expor diagnostico em `Explain` ou API futura para indicar se um shape usaria generated ou runtime; -- preservar annotations RUC/RDC nas APIs que ainda podem usar fallback runtime. - -## Compatibility Impact - -Uma futura implementacao pode ser minor-compatible se: - -- nao remover `QueryMapped*` runtime; -- nao exigir generator para consumidores atuais; -- nao alterar `Dapper.Query()`; -- nao remover public mutable dictionaries; -- nao tornar `Initialize(...)` one-shot; -- nao mudar TypeHandler semantics. - -Possivel impacto publico futuro: - -- pacote generator precisaria emitir materializer/manifest alem de registro; -- o core pode precisar de uma API publica pequena para registrar/descrever materializers gerados; -- diagnostics podem ganhar metadados de generated/fallback. - -## Risks - -- O generator pode aceitar apenas um subconjunto da DSL e surpreender consumidores se fallback nao for claro. -- Map constructors sao codigo arbitrario; tentar interpreta-los demais aumenta falso positivo/falso negativo. -- Public mutable dictionaries podem invalidar a correspondencia entre descriptor gerado e configuracao efetiva. -- TypeHandlers continuam sendo o ponto Dapper-specific mais delicado. -- Conventions customizadas e naming policies dinamicas podem limitar a cobertura gerada. -- Geracao por assembly exige desenho para dependencies e duplicidades. -- Sem benchmark, ganho de performance permanece hipotese. -- Sem Native AOT runtime, compatibilidade AOT completa permanece nao provada. - -## Recommendation - -`GO WITH CONSTRAINTS` - -E tecnicamente viavel gerar materializers de `DbDataReader` para um subconjunto estatico dos mappings do FluentMap: explicit maps com colunas literais, profiles tipados, paths por `MemberPath`, nested mutable objects e Value Objects por construtores publicos. A PoC test-only prova a forma essencial do codigo sem reflection/dynamic code no hot path. - -As restricoes sao obrigatorias: - -- generated materializer deve complementar, nao substituir, o runtime; -- fallback runtime deve permanecer a politica default para maps dinamicos; -- a primeira implementacao deve focar explicit/profile maps geraveis; -- TypeHandler gerado precisa de decisao propria antes de virar contrato; -- AOT deve ser validado por publish/run real antes de remover ou relaxar annotations publicas; -- performance precisa de benchmark antes de claims. - -## Proposed Next Stage - -Etapa 7 - Generated Materialization - -Sequencia sugerida derivada do spike: - -1. `Generated Materializer Contract` - - definir descriptor gerado, lookup por entity/profile/column shape e politica de fallback; - - decidir impacto publico minimo. -2. `Static Mapping DSL Discovery` - - estender generator para detectar somente `Map(...).ToColumn("literal")`, `Ignore`, `IncludeBase` e `IProfileMap`; - - emitir diagnostics informativos para maps nao geraveis. -3. `Generated Row Materializer Prototype` - - gerar materializer para simple root properties, nested mutable objects, immutable constructors e `DBNull` semantics; - - manter runtime fallback. -4. `Generated Profiles And Diagnostics` - - cobrir `TProfile`, inherited profile maps, `Explain`/diagnostic de generated vs fallback. -5. `TypeHandler And Conversion Strategy` - - escolher API/boundary para TypeHandlers sem espalhar reflection; - - validar nullable handlers. -6. `AOT/Trim And Performance Validation` - - publish trimmed; - - Native AOT em ambiente com linker C++; - - benchmark de startup, first query, throughput, allocation e memory. - -Dependencies: - -- manter Etapa 6 lifecycle; -- manter snapshots/read-only APIs; -- preservar compatibility boundary do Dapper; -- nao depender de Dommel; -- manter core `netstandard2.0`. - -Migration approach: - -- generator opt-in; -- runtime permanece autoritativo; -- fallback transparente; -- diagnostics para cobertura gerada. - -Testing strategy: - -- generator unit tests para discovery e codigo emitido; -- integration tests com SQLite para generated path; -- regression tests comparando runtime e generated para os mesmos SQL shapes; -- tests de fallback dinamico; -- trimmed smoke; -- Native AOT smoke quando ambiente permitir; -- benchmarks separados. - -## Validation Results - -Environment: - -- SDK: `10.0.302`; -- test runner detected: VSTest with xUnit v3; -- core target: `netstandard2.0`; -- test target: `net10.0`. - -Localized PoC validation: - -```text -dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Debug --filter "FullyQualifiedName~GeneratedMaterializerSpikeTests" -``` - -Result: - -- success; -- 2 tests passed. - -Mandatory validation: - -```text -dotnet restore .\Dapper.FluentMap.sln -dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore -dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build -dotnet pack .\Dapper.FluentMap.sln --configuration Release --no-build --output .\artifacts\packages -``` - -Results: - -- restore: success; -- build: success, 0 warnings, 0 errors; -- tests: success, 231 total tests passed: - - core: 200; - - Dommel: 7; - - analyzers: 9; - - generators: 14; - - generated-registration integration: 1; -- pack: success: - - `Dapper.FluentMap.2.0.0.nupkg`; - - `Dapper.FluentMap.Dommel.2.0.0.nupkg`; - - `Dapper.FluentMap.Analyzers.2.0.0.nupkg`; - - `Dapper.FluentMap.Generators.2.0.0.nupkg`. - -Known pack warnings: - -- `NU5125` for legacy `PackageLicenseUrl` in core and Dommel; -- NuGet README recommendation for core and Dommel. - -These warnings are pre-existing package metadata debt tracked outside this delivery. diff --git a/docs/sdd/etapa-6/README.md b/docs/sdd/etapa-6/README.md deleted file mode 100644 index a2f4074..0000000 --- a/docs/sdd/etapa-6/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# Etapa 6 - Architectural Hardening - -Etapa 6 Status: COMPLETED - -## Objective - -Formalizar contratos arquiteturais que reduzem ambiguidade sobre estado global, lifecycle de configuracao, integracao com Dapper e futuros caminhos de materializacao. - -Esta etapa preserva a compatibilidade publica existente do core `Dapper.FluentMap` e usa Specification-Driven Development para separar contrato, decisao e implementacao. - -## Deliveries - -| Delivery | Title | Status | Notes | -|---|---|---|---| -| 01 | Configuration Lifecycle | COMPLETED | Lifecycle suportado e mutacoes de runtime formalizados. | -| 02 | Mapping State Encapsulation | COMPLETED | Snapshots read-only adicionados e superficie mutavel legada documentada. | -| 03 | Dapper Compatibility Adapters | COMPLETED | Compatibility boundary interno adicionado; TypeHandler reflection isolada; ignored sentinel removido. | -| 04 | Generated Materializer Spike | COMPLETED | Viabilidade tecnica confirmada com restricoes; generated + runtime fallback recomendado. | - -## Delivery List - -01 Configuration Lifecycle COMPLETED -02 Mapping State Encapsulation COMPLETED -03 Dapper Compatibility Adapters COMPLETED -04 Generated Materializer Spike COMPLETED - -## Sources Of Truth - -- `docs/sdd/fluentmap-risk-assessment.md` -- `docs/sdd/etapa-1/` -- `docs/sdd/etapa-2/` -- `docs/sdd/etapa-3/` -- `docs/sdd/etapa-4/` -- `docs/sdd/etapa-5/` -- `src/Dapper.FluentMap/FluentMapper.cs` -- `src/Dapper.FluentMap/MappingRegistry.cs` - -## Results - -- [01 Configuration Lifecycle](01-configuration-lifecycle.md): formalizou configuracao em startup seguida de operacao read-only, preservando mutacoes legadas apenas sob quiescencia externa. -- [02 Mapping State Encapsulation](02-mapping-state-encapsulation.md): adicionou snapshots read-only e manteve campos publicos mutaveis como superficie legada. -- [03 Dapper Compatibility Adapters](03-dapper-compatibility-adapters.md): isolou detalhes Dapper-specific, centralizou TypeHandler reflection e removeu `IgnoredPropertyInfo`. -- [04 Generated Materializer Spike](04-generated-materializer-spike.md): concluiu `GO WITH CONSTRAINTS` para materializer gerado com fallback runtime. - -## Summary - -Etapa 6 preservou compatibilidade publica e consolidou contratos para proximas mudancas de materializacao. O estado global ainda existe, mas o lifecycle foi documentado; leitura segura de estado recebeu snapshots; a compatibilidade com Dapper ficou atras de adapters internos; e o spike mostrou que um generated `DbDataReader` materializer e viavel para mappings estaticos, desde que coexista com fallback runtime para configuracao dinamica. diff --git a/docs/sdd/etapa-6/decisions.md b/docs/sdd/etapa-6/decisions.md deleted file mode 100644 index cce29d9..0000000 --- a/docs/sdd/etapa-6/decisions.md +++ /dev/null @@ -1,196 +0,0 @@ -# Decisoes Da Etapa 6 - -Registre aqui apenas decisoes arquiteturais necessarias as proximas entregas. - -## E6-D001 - Configuration Lifecycle Contract - -O lifecycle publico suportado do FluentMap passa a ser descrito em duas fases: - -```text -Configuration Phase - | - v -Operational Phase -``` - -Durante a `Configuration Phase`, consumidores devem registrar maps, profiles, conventions e naming policies, e podem chamar `Validate()` para falhar cedo. Chamadas repetidas de `FluentMapper.Initialize(...)` continuam permitidas para configuracao aditiva, sujeitas as validacoes e regras de duplicidade ja existentes. - -Ao iniciar queries por `Dapper.Query()`, `QueryMapped()` ou APIs equivalentes, a aplicacao entra na `Operational Phase` para os tipos usados. Nessa fase, a configuracao efetiva deve ser tratada como read-only pelo consumidor. - -Mutacoes depois do inicio das queries permanecem possiveis por compatibilidade binaria/fonte, mas so sao suportadas quando o consumidor garante quiescencia externa: sem queries concorrentes, sem leitores/materializers em execucao para os tipos afetados e com entendimento de que `SqlMapper.SetTypeMap` altera estado global do Dapper. O FluentMap nao garante determinismo para reconfiguracao concorrente em runtime. - -## E6-D002 - Documentation Contract Only For Delivery 01 - -Esta entrega escolhe `A. Documentation Contract Only`. - -Justificativa: - -- `FluentMapper.Initialize(...)` historicamente executa mutacoes imediatas sobre uma instancia estatica de `FluentMapConfiguration`. -- `AddMap`, `AddProfile`, conventions e naming policies sao APIs publicas aditivas ou historicas. -- `FluentMapper.EntityMaps` e `FluentMapper.TypeConventions` continuam publicos e mutaveis por compatibilidade. -- `MappingRegistry.Reset(...)` e interno e usado para isolamento de testes, nao como contrato publico de runtime. -- Adicionar `Freeze()`, `Seal()` ou exceptions depois da primeira query quebraria comportamento atualmente possivel sem uma estrategia de migracao. - -A entrega documenta o contrato, adiciona testes de caracterizacao e prepara a Entrega 02 para encapsulamento de estado. Nenhuma API publica foi removida, nenhuma API de freeze foi adicionada e nenhum enforcement de runtime foi introduzido. - -## E6-D003 - Profiles Remain Query-Scoped - -Profiles continuam sendo alternativa query-scoped para SQL shapes diferentes da mesma entidade. - -O contrato preservado e: - -- `Dapper.Query()` usa apenas o default map instalado no type map global do Dapper. -- `QueryMapped()` seleciona o profile por operacao. -- Profiles nao trocam `SqlMapper.SetTypeMap` temporariamente. -- Conventions e naming policies permanecem por entidade e sao lidas por profiles sem mutacao global por query. - -Qualquer entrega futura que tente aplicar profiles ao caminho `Dapper.Query()`, multi-mapping ou Dommel deve tratar isso como nova decisao arquitetural. - -## E6-D004 - Mapping State Read-Only Snapshots - -Entrega 02 escolhe encapsulamento incremental sem breaking change. - -`FluentMapper.EntityMaps` e `FluentMapper.TypeConventions` permanecem campos publicos mutaveis do mesmo tipo para preservar compatibilidade de fonte e binaria. Eles nao foram marcados com `[Obsolete]` nesta entrega porque isso poderia quebrar consumidores que tratam warnings como erros. - -Novas APIs publicas de leitura foram adicionadas: - -```csharp -FluentMapper.GetEntityMaps() -FluentMapper.GetTypeConventions() -``` - -Elas retornam snapshots read-only, nao o `ConcurrentDictionary` vivo nem listas mutaveis de conventions. O objetivo e oferecer uma superficie oficial para inspecao e migracao sem permitir mutacao acidental pelo novo caminho. - -Toda mutacao oficial continua passando conceitualmente por: - -```text -Consumer API - | - v -FluentMapper / FluentMapConfiguration - | - v -MappingRegistry - | - v -Validation - | - v -Cache invalidation - | - v -Dapper integration -``` - -Mutacoes diretas nos campos legados continuam possiveis, podem ignorar invariantes e exigem migracao futura de major version para serem removidas ou substituidas por propriedades read-only. - -## E6-D005 - Dapper Compatibility Boundary - -Entrega 03 cria uma fronteira interna explicita para detalhes de compatibilidade com Dapper no namespace `Dapper.FluentMap.Compatibility`. - -Essa fronteira concentra: - -- invocacao de TypeHandlers registrados no Dapper por `DapperTypeHandlerAdapter`; -- exposicao de property mappings ao Dapper por `DapperFluentPropertyTypeMap`; -- `IMemberMap` seguro para propriedades simples por `DapperPropertyMemberMap`; -- marker seguro para ignored/nested por `DapperIgnoredMemberMap`. - -Nenhuma API publica foi adicionada. O objetivo e manter detalhes Dapper-specific fora do materializer e reduzir o numero de pontos onde uma mudanca interna do Dapper pode afetar o FluentMap. - -## E6-D006 - Residual TypeHandler Reflection - -Nao foi encontrada no Dapper `2.1.79` uma API publica que converta um `object` usando o TypeHandler registrado para um tipo arbitrario. - -Por isso, a reflection residual para `SqlMapper.TypeHandlerCache.Parse(object)` permanece, mas fica isolada em `DapperTypeHandlerAdapter`. Se a shape esperada nao existir em uma versao futura do Dapper, o FluentMap deve falhar com `FluentMapConfigurationException` diagnosticavel em vez de cair silenciosamente para `Convert.ChangeType`. - -Esse risco fica `MITIGATED`, nao `RESOLVED`, ate existir alternativa publica suportada pelo Dapper ou ate o FluentMap deixar de precisar invocar handlers no materializer runtime. - -## E6-D007 - Ignored Mapping Without Throwing PropertyInfo Sentinel - -Entrega 03 remove `IgnoredPropertyInfo`. - -Mappings ignored e nested deixam de passar por `CustomPropertyTypeMap` para retornar um `PropertyInfo` falso. O caminho atual retorna um `DapperIgnoredMemberMap`, que implementa `SqlMapper.IMemberMap` com propriedades seguras e nulas. `MultiTypeMap` reconhece esse marker e retorna `null` sem continuar para `DefaultTypeMap`, preservando o bloqueio de fallback. - -Com isso, `FM-RISK-012` fica `RESOLVED`: nao ha mais sentinel `PropertyInfo` interno com membros que lancam `NotImplementedException`. - -## E6-D008 - Dapper Upgrade Checklist - -Qualquer upgrade futuro de Dapper deve revisar explicitamente: - -- `SqlMapper.ITypeMap`; -- `SqlMapper.IMemberMap`; -- `DefaultTypeMap` constructor/member behavior; -- `SqlMapper.SetTypeMap` global state; -- `SqlMapper.HasTypeHandler`; -- `SqlMapper.TypeHandlerCache.Parse(object)`; -- comportamento de fallback quando um mapper retorna `null`; -- testes `DapperCompatibilityAdapterTests`, `ValueObjectMaterializationTests`, `ConstructorMappingTests`, `NestedMaterializationSpikeTests`, `DapperIntegrationTests` e Dommel. - -## E6-D009 - Generated Materializer Direction - -O spike da Entrega 04 conclui `GO WITH CONSTRAINTS` para materializacao gerada. - -A arquitetura futura recomendada e: - -```text -QueryMapped - | - v -Generated materializer available and matching? - | yes - v -Generated materializer - | - no - v -Runtime NestedMaterializationPlan fallback -``` - -Um caminho generated-only foi rejeitado como arquitetura default porque quebraria configuracao dinamica, assembly scanning, conventions nao geraveis, maps em assemblies externos sem manifest e a superficie legada de mutacao publica ainda preservada por compatibilidade. - -## E6-D010 - Static Mapping Eligibility - -Materializers gerados devem ser usados apenas quando o generator conseguir provar estaticamente o mapping efetivo. - -Primeiro subconjunto elegivel: - -- maps declarados na compilacao atual; -- `Map(...).ToColumn("literal")`; -- `Ignore()`; -- `IncludeBase()` quando o base map tambem for geravel; -- profiles por `IProfileMap`; -- construtores publicos e setters publicos representaveis pelo symbol model. - -Devem cair para fallback runtime: - -- column names dinamicos; -- helper methods arbitrarios; -- assembly scanning; -- public dictionary mutation; -- conventions customizadas nao geraveis; -- naming policies aplicadas dinamicamente; -- maps de assemblies externos sem descriptor gerado. - -## E6-D011 - AOT Claims Require Runtime Evidence - -Generated materializers podem reduzir dependencia de `Expression.Compile`, `Activator` e reflection no hot path para casos estaticos, mas isso nao basta para declarar compatibilidade Native AOT. - -Qualquer etapa futura deve separar: - -- `Proven`: validado por build/publish/run; -- `Likely`: inferido de codigo gerado e analyzers; -- `Unknown`: dependente de Dapper, TypeHandlers, ambiente Native AOT ou configuracao dinamica. - -As annotations `RequiresUnreferencedCode` e `RequiresDynamicCode` das APIs que podem usar fallback runtime nao devem ser removidas ate haver um caminho publico que garanta generated-only sem fallback reflection-based. - -## E6-D012 - Generated TypeHandler Boundary - -TypeHandlers permanecem a area mais sensivel para materializer gerado. - -Codigo gerado nao deve espalhar reflection para internals do Dapper nem chamar APIs version-sensitive sem uma decisao propria. Uma etapa futura deve escolher entre: - -- uma pequena API/boundary publica no core para conversao gerada; -- chamada direta gerada a uma shape publica do Dapper, aceitando diagnostico/compile failure em upgrades; -- fallback runtime quando TypeHandler for necessario. - -A decisao E6-D006 permanece vigente ate essa escolha ser implementada e validada. diff --git a/docs/sdd/etapa-6/handoff.md b/docs/sdd/etapa-6/handoff.md deleted file mode 100644 index e368363..0000000 --- a/docs/sdd/etapa-6/handoff.md +++ /dev/null @@ -1,150 +0,0 @@ -# Etapa 6 Handoff - -## Etapa 6 Final State - -Etapa 6 esta `COMPLETED`. - -O objetivo foi endurecer contratos arquiteturais antes de qualquer mudanca grande no materializer. A etapa preservou API publica, manteve `Dapper.FluentMap` em `netstandard2.0` e nao alterou Dommel funcionalmente. - -## Completed Deliveries - -01 Configuration Lifecycle - `COMPLETED` - -- Contrato formal: `Configuration Phase -> Operational Phase`. -- Runtime reconfiguration continua possivel por compatibilidade, mas apenas sob quiescencia externa. -- Direct public dictionary mutation permanece superficie legada. - -02 Mapping State Encapsulation - `COMPLETED` - -- Adicionados snapshots read-only: - - `FluentMapper.GetEntityMaps()`; - - `FluentMapper.GetTypeConventions()`. -- Campos publicos mutaveis foram preservados por compatibilidade. - -03 Dapper Compatibility Adapters - `COMPLETED` - -- Criada fronteira interna `Dapper.FluentMap.Compatibility`. -- `DapperTypeHandlerAdapter` concentra reflection residual de TypeHandlers. -- `IgnoredPropertyInfo` foi removido. -- Ignored/nested mappings usam `DapperIgnoredMemberMap`. - -04 Generated Materializer Spike - `COMPLETED` - -- Resultado: `GO WITH CONSTRAINTS`. -- Prototipo test-only validou materializer gerado conceitual para entidade simples, nested mutable object, immutable Value Object, profile e `DBNull`. -- Recomendacao: generated materializer com runtime fallback. - -## Architecture After Etapa 6 - -`FluentMapper` permanece uma fachada global: - -- static `MappingRegistry`; -- static `FluentMapConfiguration`; -- public mutable `EntityMaps`; -- public mutable `TypeConventions`; -- Dapper global type-map integration por `SqlMapper.SetTypeMap`. - -O lifecycle suportado e: - -```text -Configuration Phase - | - v -Operational Phase -``` - -Profiles permanecem query-scoped: - -```text -QueryMapped() -``` - -Eles nao trocam `SqlMapper.SetTypeMap` temporariamente. - -`QueryMapped*` ainda usa `NestedMaterializationPlan` runtime/reflection-based. O futuro caminho recomendado e: - -```text -QueryMapped - | - v -Generated materializer matches? - | yes - v -Generated path - | - no - v -Runtime fallback -``` - -## Remaining Risks - -- `FM-RISK-001`: global FluentMap/Dapper state permanece mitigado, nao resolvido. -- `FM-RISK-002`: public mutable dictionaries ainda podem bypassar registry/cache. -- `FM-RISK-004`: materializer gerado ainda nao existe em runtime de producao. -- `FM-RISK-005`: `QueryMapped*` ainda bufferiza todas as linhas. -- `FM-RISK-006`: factory methods/private constructors/private setters/fields/NRT continuam fora do contrato. -- `FM-RISK-007`: TypeHandler invocation ainda depende de reflection isolada. -- `FM-RISK-008`: conventions/naming policies por profile ainda nao existem. -- `FM-RISK-009`: profiles ainda nao se aplicam a `Dapper.Query` ou multi-mapping. - -## Resolved Risks - -- `FM-RISK-012`: `IgnoredPropertyInfo` foi removido; ignored/nested mappings usam marker seguro. - -## Mitigated Risks - -- `FM-RISK-001`: lifecycle documentado e testado. -- `FM-RISK-003`: scanning marcado/documentado como trimming-sensitive; explicit/generated registration permanecem caminhos preferidos. -- `FM-RISK-004`: spike adicionou evidencia tecnica e arquitetura recomendada, mas nao resolveu o runtime. -- `FM-RISK-007`: reflection Dapper-specific isolada em `DapperTypeHandlerAdapter`. -- `FM-RISK-014`: analyzer/generator permanecem complementares a validacao runtime. - -## Decisions That Future Work Must Preserve - -- E6-D001 - Configuration lifecycle contract. -- E6-D002 - Documentation contract only for Delivery 01; sem freeze/seal API. -- E6-D003 - Profiles remain query-scoped. -- E6-D004 - Mapping state read-only snapshots. -- E6-D005 - Dapper compatibility boundary. -- E6-D006 - Residual TypeHandler reflection isolated and diagnostic. -- E6-D007 - Ignored mapping without throwing `PropertyInfo` sentinel. -- E6-D008 - Dapper upgrade checklist. -- E6-D009 - Generated materializer direction: generated + runtime fallback. -- E6-D010 - Static mapping eligibility. -- E6-D011 - AOT claims require runtime evidence. -- E6-D012 - Generated TypeHandler boundary. - -## Recommended Next Stage - -Etapa 7 - Generated Materialization - -Suggested sequence: - -1. Generated materializer contract and runtime lookup. -2. Static mapping DSL discovery in the generator. -3. Generated row materializer for explicit maps, nested mutable objects, immutable constructors and `DBNull`. -4. Generated profile support and diagnostics. -5. TypeHandler/conversion strategy. -6. Trim, Native AOT and performance validation. - -## Preconditions - -- Preserve source/binary compatibility. -- Keep runtime fallback. -- Keep `Dapper.Query` default behavior unchanged. -- Keep profiles query-scoped and avoid `SqlMapper.SetTypeMap` mutation scopes. -- Do not require generator installation for existing consumers. -- Do not remove RUC/RDC annotations while runtime fallback remains possible. -- Validate with generator tests, integration tests, trimmed smoke and Native AOT runtime when environment supports it. - -## Open Questions - -- What minimal public or internal contract should connect generated materializers to the core lookup? -- How should generated descriptors prove they still match effective runtime configuration when public dictionaries can be mutated directly? -- Should first generated support include built-in naming policies or explicit maps only? -- What is the safest TypeHandler strategy without spreading Dapper-internal reflection? -- How should maps in referenced assemblies expose generated materializer manifests? -- What diagnostics should explain generated path vs fallback? -- What benchmark shape should become the baseline for startup, first query, throughput, allocation and memory? -- Which environment will validate Native AOT runtime with the required platform linker C++ installed? diff --git a/docs/sdd/fluentmap-risk-assessment.md b/docs/sdd/fluentmap-risk-assessment.md deleted file mode 100644 index 6e88a13..0000000 --- a/docs/sdd/fluentmap-risk-assessment.md +++ /dev/null @@ -1,1027 +0,0 @@ -# FluentMap - Consolidated Risk Assessment - -## 1. Executive Summary - -This assessment reconstructs the FluentMap SDD history from `docs/sdd/etapa-1` through `docs/sdd/etapa-5`, plus the `.NET 10` migration and the SQLite dependency hardening. It consolidates only risks that still have current evidence as `OPEN`, `MITIGATED`, or `UNKNOWN`. Historical items that were later resolved or superseded are listed separately in section 10. - -Development history reconstructed from repository evidence: - -```text -Etapa 1 - - 01 ReflectionHelper - - 02 Mapping composition - - 03 Dapper integration tests - - 04 MappingRegistry and cache - -Etapa 2 - - 01 MemberPath - - 02 Configuration validation and diagnostics - - 03 Inherited mappings - - 04 Naming policies - -Etapa 3 - - 01 Mapping registration and discovery - - 02 Constructor mapping and immutable types - - 03 Validate and Explain - -Etapa 4 - - 01 Roslyn analyzers - - 02 Trimming and Native AOT - - 03 Source generator - -Etapa 5 - - 01 Nested/value-object materialization spike - - 02 Nested object materialization - - 03 Immutable value objects - - 04 Mapping profiles - -.NET 10 migration - - 01 Inventory and baseline - - 02 Test projects on net10.0 - - 03 Source project dependencies - - 04 Validation, pack and CI - - 05 xUnit 3 migration - -Security hardening - - SQLitePCLRaw vulnerability correction -``` - -Current risk count: - -- Total current items: 18 -- Critical: 0 -- High: 3 -- Medium: 10 -- Low: 5 -- Open: 9 -- Mitigated: 6 -- Resolved: 1 -- Unknown: 2 - -Overall, FluentMap is not in a critical architectural state. The main runtime contract is well protected by SDD decisions, integration tests, fail-fast validation, `MemberPath`, cache keys, and query-scoped profiles. The largest remaining risks come from intentionally preserved global/static compatibility surfaces, trimming/AOT constraints, and the fact that the new materializer is runtime/reflection-based rather than generated. - -## 2. Risk Distribution - -| Severity | Count | -| -------- | ----: | -| Critical | 0 | -| High | 3 | -| Medium | 10 | -| Low | 5 | - -## 3. Priority Matrix - -| ID | Problem | Severity | Probability | Area | Origin | Status | -| -- | ------- | -------- | ----------- | ---- | ------ | ------ | -| FM-RISK-001 | Global FluentMap/Dapper mapping state constrains thread safety and runtime reconfiguration | HIGH | Medium | Concurrency, Thread Safety | Etapa 1 / Entrega 03-04 | MITIGATED | -| FM-RISK-002 | Public mutable dictionaries can bypass registry validation and cache invalidation | HIGH | Medium | Architecture, Compatibility | Etapa 1 / Entrega 04 | OPEN | -| FM-RISK-003 | Assembly scanning can fail under trimming/AOT and produced a failing trimmed smoke | HIGH | Medium | Reflection, Compatibility | Etapa 3 / Entrega 01; Etapa 4 / Entrega 02 | MITIGATED | -| FM-RISK-004 | `QueryMapped*` remains runtime/reflection/dynamic-code based; no generated materializer exists | MEDIUM | Medium | AOT, Performance | Etapa 5 / Entrega 02-04 | MITIGATED | -| FM-RISK-005 | `QueryMapped*` buffers all rows and has no streaming/unbuffered mode | MEDIUM | Medium | Performance, Memory | Etapa 5 / Entrega 04 | OPEN | -| FM-RISK-006 | Value Object support excludes factories, private constructors/setters, fields and NRT semantics | MEDIUM | Medium | Value Objects, API Design | Etapa 5 / Entrega 03 | OPEN | -| FM-RISK-007 | Dapper TypeHandler integration in the runtime materializer depends on reflective access to `SqlMapper.TypeHandlerCache` | MEDIUM | Low | Compatibility, Reflection | Etapa 5 / Entrega 03 | MITIGATED | -| FM-RISK-008 | Mapping profiles do not support per-profile conventions/naming policies | MEDIUM | Medium | Profiles, Extensibility | Etapa 5 / Entrega 04 | OPEN | -| FM-RISK-009 | Mapping profiles do not apply to `Dapper.Query` or Dapper multi-mapping | MEDIUM | Medium | Profiles, API Design | Etapa 5 / Entrega 04 | OPEN | -| FM-RISK-010 | Legacy `ApplyMapsFromAssemblies` keeps older reflection/discovery behavior | MEDIUM | Low | Reflection, Maintainability | Etapa 2 / Entrega 02; Etapa 3 / Entrega 01 | MITIGATED | -| FM-RISK-011 | Constructor overload ambiguity and optional parameters remain delegated to Dapper | MEDIUM | Low | Materialization, Correctness | Etapa 3 / Entrega 02 | OPEN | -| FM-RISK-012 | Throwing `IgnoredPropertyInfo` sentinel was removed from ignored/nested mapping paths | MEDIUM | Low | Correctness, Maintainability | Etapa 2 / Entrega 02; Etapa 6 / Entrega 03 | RESOLVED | -| FM-RISK-013 | Dommel behavior for profiles/nested materialization is intentionally unreviewed | MEDIUM | Low | Dommel, Extensibility | Etapa 5 / Entrega 04 | UNKNOWN | -| FM-RISK-014 | Analyzer and generator coverage is intentionally partial | LOW | High | Developer Experience, Testing | Etapa 4 / Entrega 01-03 | MITIGATED | -| FM-RISK-015 | Async `QueryMapped*` overloads are asymmetric: profile async exists, default async does not | LOW | Medium | API Design | Etapa 5 / Entrega 04 | OPEN | -| FM-RISK-016 | NuGet package metadata remains legacy (`PackageLicenseUrl`, no package README/SourceLink metadata) | LOW | High | Documentation, Developer Experience, Packaging | .NET 10 / Entrega 04; Security hardening | OPEN | -| FM-RISK-017 | Remote CI execution remains unproven after CI modernization | LOW | Medium | Testing, Maintainability | .NET 10 / Entrega 04-05 | UNKNOWN | -| FM-RISK-018 | Documentation carries archived/legacy signals alongside new SDD features | LOW | Medium | Documentation | README and SDD summaries | OPEN | - -## 4. Critical Risks - -No critical risks were identified from the available evidence. - -## 5. High Risks - -## FM-RISK-001 - Global FluentMap/Dapper mapping state constrains thread safety and runtime reconfiguration - -**Severidade:** HIGH -**Status:** MITIGATED -**Categoria:** Architecture, Concurrency, Thread Safety, Mapping, Compatibility -**Origem:** Etapa 1 / Entregas 03 and 04 -**Detectado em:** planning, implementation and test review -**Componentes afetados:** `FluentMapper`, `MappingRegistry`, `SqlMapper.SetTypeMap`, tests using global reset - -### Descricao - -FluentMap still relies on process-wide mapping state and Dapper's global type-map registry. The code now uses `ConcurrentDictionary`, structured cache keys and invalidation, but the architecture remains global. Runtime reconfiguration while queries are executing is not proven safe as a public contract. - -### Evidencias - -- `docs/sdd/etapa-1/03-dapper-integration-tests.md`: identifies static `EntityMaps`, static `TypeConventions`, static `_configuration`, `SqlMapper.SetTypeMap`, cache interference and disabled parallelism. -- `docs/sdd/etapa-1/04-mapping-registry-cache.md`: resolves cache key and reset issues but explicitly keeps public mutable dictionaries, Dapper global type maps and disabled parallelism. -- `docs/sdd/net10-migration/05-xunit3-migration.md`: preserves `[assembly: CollectionBehavior(DisableTestParallelization = true)]` because tests use global FluentMapper/Dapper/Dommel state. -- `src/Dapper.FluentMap/FluentMapper.cs`: static `_registry`, static `_configuration`, public static `EntityMaps` and `TypeConventions`. -- `src/Dapper.FluentMap/MappingRegistry.cs`: `SetDapperTypeMap` calls `SqlMapper.SetTypeMap(type, instance)`. -- `docs/sdd/etapa-6/02-mapping-state-encapsulation.md`: adds read-only mapping snapshots while preserving mutable compatibility fields. -- `test/Dapper.FluentMap.Tests/MappingStateEncapsulationTests.cs`: validates official mutation cache/Dapper behavior, read-only snapshots, profile isolation and legacy bypass behavior. -- `test/Dapper.FluentMap.Tests/ManualMappingTests.cs` and `test/Dapper.FluentMap.Dommel.Tests/ManualMappingTests.cs`: assembly-level test parallelization disabled. -- `docs/sdd/etapa-6/01-configuration-lifecycle.md`: defines the supported lifecycle as startup configuration followed by read-only operation, with runtime mutation allowed only under external quiescence for compatibility. -- `test/Dapper.FluentMap.Tests/ConfigurationLifecycleTests.cs`: characterizes repeated additive `Initialize`, serialized runtime registration compatibility, and direct dictionary mutation bypassing Dapper type-map installation. - -### Cenario de impacto - -An application dynamically reinitializes mappings for the same entity while requests are still materializing rows. One request can observe old mappings, another can observe new mappings, and Dapper's global type-map registry can be replaced mid-flight. - -### Impacto - -Potential non-deterministic mapping behavior, hard-to-reproduce test failures, and incorrect materialization if consumers treat `Initialize` as a runtime mutation API instead of startup configuration. - -### Probabilidade - -Media. The normal startup-once usage is safe enough, but the public static shape makes runtime mutation possible and tests remain serialized because of it. - -### Workaround atual - -Configure FluentMap once during application startup. Avoid mutating mappings after queries begin. Use query-scoped profiles for alternate shapes instead of replacing Dapper type maps. - -### Recomendacao - -Use the Etapa 6 lifecycle contract as the public boundary: configure during startup, validate, and treat the effective configuration as read-only during operation. Investigate an immutable snapshot registry and reduced public mutability for future versions. Any stronger runtime enforcement must preserve source/binary compatibility or be planned as a major version. - -### Relacoes - -Related to FM-RISK-002, FM-RISK-005, FM-RISK-013 and the Etapa 5 research item "cache imutavel/snapshot". - -## FM-RISK-002 - Public mutable dictionaries can bypass registry validation and cache invalidation - -**Severidade:** HIGH -**Status:** OPEN -**Categoria:** Architecture, Correctness, API Design, Compatibility, Technical Debt -**Origem:** Etapa 1 / Entrega 04 -**Detectado em:** implementation decision and compatibility review -**Componentes afetados:** `FluentMapper.EntityMaps`, `FluentMapper.TypeConventions`, `MappingRegistry` - -### Descricao - -`FluentMapper.EntityMaps` and `FluentMapper.TypeConventions` remain public mutable dictionaries for compatibility. Consumers can mutate them directly, bypassing `MappingRegistry.AddEntityMap`, `AddConvention`, validation, cache invalidation and Dapper type-map installation. - -### Evidencias - -- `docs/sdd/etapa-1/04-mapping-registry-cache.md`: explicitly lists direct public mutation as deliberately unresolved. -- `docs/sdd/etapa-1/decisions.md`: keeps public dictionaries and says reducing their mutability is a compatibility-planned change. -- `docs/sdd/etapa-3/03-diagnostics-api.md`: keeps the dictionaries public for compatibility. -- `src/Dapper.FluentMap/FluentMapper.cs`: exposes `public static readonly ConcurrentDictionary EntityMaps` and `public static readonly ConcurrentDictionary> TypeConventions`. -- `src/Dapper.FluentMap/FluentMapper.cs`: also exposes `GetEntityMaps()` and `GetTypeConventions()` read-only snapshots as the preferred inspection API. -- `src/Dapper.FluentMap/MappingRegistry.cs`: validation and invalidation happen only through registry methods, not through arbitrary dictionary mutation. -- `test/Dapper.FluentMap.Tests/MappingStateEncapsulationTests.cs`: characterizes direct map replacement leaving a cached mapping stale, proving the legacy bypass still exists. - -### Cenario de impacto - -A consumer directly assigns `FluentMapper.EntityMaps[typeof(Customer)] = new CustomerMap()` after a miss for a column was cached. The registry may not invalidate the existing cache entry or reinstall the Dapper type map for that type. - -### Impacto - -Mappings can be silently ignored or stale. Diagnostics may disagree with materialization, and failures can be hard to attribute to direct dictionary mutation. - -### Probabilidade - -Media. Direct dictionary access is public and historically available, but most documented examples use `Initialize`. - -### Workaround atual - -Use `FluentMapper.Initialize`, `AddMap`, `AddMap`, `AddProfile` and convention APIs only. Use `FluentMapper.GetEntityMaps()` and `FluentMapper.GetTypeConventions()` for read-only inspection. Do not mutate `EntityMaps` or `TypeConventions` directly. - -### Recomendacao - -Keep direct mutation documented as legacy compatibility surface. Use the new read-only snapshots as the preferred inspection API and plan a future major version that replaces public mutable fields with read-only properties or immutable effective mapping snapshots. Consider internal detection of dictionary replacement/mutation only if it can be done without breaking consumers. - -### Relacoes - -Related to FM-RISK-001 and FM-RISK-013. This is the main blocker for re-enabling test parallelism safely. - -## FM-RISK-003 - Assembly scanning can fail under trimming/AOT and produced a failing trimmed smoke - -**Severidade:** HIGH -**Status:** MITIGATED -**Categoria:** Reflection, Compatibility, Architecture, Developer Experience -**Origem:** Etapa 3 / Entrega 01; Etapa 4 / Entrega 02 -**Detectado em:** planning, implementation and trimming smoke validation -**Componentes afetados:** `AddMapsFromAssembly`, `AddMapsFromAssemblyContaining`, `ForEntitiesInAssembly`, `ForEntitiesInCurrentAssembly`, `ApplyMapsFromAssemblies` - -### Descricao - -Assembly scanning remains supported for normal runtime usage, but it is reflection-dependent and trimming-sensitive. The trimmed scanning smoke published successfully with expected warnings and then failed at runtime because mapping metadata was removed. - -### Evidencias - -- `docs/sdd/etapa-3/01-mapping-registration.md`: documents scanning via reflection and `Activator.CreateInstance` as remaining AOT/trimming debt. -- `docs/sdd/etapa-4/02-trimming-aot.md`: classifies scanning APIs as reflection-dependent, marks them with `RequiresUnreferencedCode`, and records a trimmed scanning runtime failure. -- `docs/sdd/etapa-4/03-source-generator.md`: positions generated registration as the alternative to scanning, but not as a full materializer. -- `README.md`: tells trimmed/AOT consumers to prefer explicit registration and documents scanning as trimming-sensitive. -- `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs`: scanning uses `Assembly.GetExportedTypes()` and `Activator.CreateInstance(mapType)`. -- `src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs`: convention scanning uses `GetExportedTypes()`. -- `src/Dapper.FluentMap/Utils/FluentMapConfigurationExtensions.cs`: legacy scanning uses `GetTypes()`, `MakeGenericMethod` and `Activator.CreateInstance`. - -### Cenario de impacto - -A Native AOT or trimmed application calls `AddMapsFromAssemblyContaining()`. The trimmer removes a map or interface metadata that scanning needs. The app starts with incomplete mappings, and a later query falls back to Dapper defaults or fails. - -### Impacto - -Potential missing mappings, incorrect column/property association, or startup/runtime failures in trimmed applications. - -### Probabilidade - -Media. The problem is proven in the repository smoke, but only affects consumers using scanning under trimming/AOT or ignoring analyzer/publish warnings. - -### Workaround atual - -Use `AddMap()` or the source generator `AddGeneratedMappings()` for trimmed/AOT applications. Avoid assembly scanning in publish modes that remove metadata. - -### Recomendacao - -Keep scanning as documented convenience only. Make README examples more explicit about scanning not being an AOT-friendly path, and consider analyzer guidance that flags scanning in projects with trimming/AOT properties when static evidence is reliable. - -### Relacoes - -Related to FM-RISK-004, FM-RISK-010 and Etapa 4 decisions about runtime remaining authoritative. - -## 6. Medium Risks - -## FM-RISK-004 - `QueryMapped*` remains runtime/reflection/dynamic-code based; no generated materializer exists - -**Severidade:** MEDIUM -**Status:** MITIGATED -**Categoria:** AOT, Trimming, Reflection, Performance, Materialization -**Origem:** Etapa 5 / Entregas 02, 03 and 04 -**Detectado em:** architecture decision, implementation and validation -**Componentes afetados:** `QueryMappedExtensions`, `NestedMaterializationPlan`, `Dapper.FluentMap.Generators` - -### Descricao - -Nested materialization, Value Object construction and profiles are implemented through `QueryMapped*`, which reads a data reader and builds cached runtime plans using reflection and expression compilation. This path is annotated with `RequiresUnreferencedCode` and `RequiresDynamicCode`; the generator still only generates registration, not a `DbDataReader` materializer. - -### Evidencias - -- `docs/sdd/etapa-5/02-nested-object-materialization.md`: documents runtime reflection, expression compilation, plan cache and AOT/trimming annotations. -- `docs/sdd/etapa-5/03-value-objects.md`: states `QueryMapped*` remains annotated and that generated materializer remains future work. -- `docs/sdd/etapa-5/04-mapping-profiles.md`: says generated query/materializer is deferred. -- `docs/sdd/etapa-5/README.md`: P1 item to create a generated `DbDataReader` materializer. -- `src/Dapper.FluentMap/QueryMappedExtensions.cs`: all public `QueryMapped*` methods are annotated with `RequiresUnreferencedCode` and `RequiresDynamicCode`. -- `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs`: compiles delegates for constructors, getters, setters and converters. -- `docs/sdd/etapa-6/04-generated-materializer-spike.md`: concludes `GO WITH CONSTRAINTS` for generated materializers with runtime fallback. -- `test/Dapper.FluentMap.Tests/GeneratedMaterializerSpikeTests.cs`: test-only prototype proves direct `IDataRecord` materialization for simple entity, explicit mapping, nested mutable object, immutable Value Object, profile and `DBNull` without runtime expression compilation in the prototype. - -### Cenario de impacto - -A consumer wants nested immutable Value Objects in a Native AOT application. The only implemented path is `QueryMapped*`, which requires runtime code generation and reflection metadata that AOT/trimming may reject or remove. - -### Impacto - -Limits production use in Native AOT/trimming-heavy applications and leaves performance below the potential of generated row materializers. - -### Probabilidade - -Media. This affects a narrower but increasingly important deployment style. The APIs are annotated, reducing surprise. - -### Workaround atual - -Use explicit/generated registration for startup mapping and avoid `QueryMapped*` in Native AOT until a generated materializer exists. Use Dapper's normal `Query` for simple root mappings. - -### Recomendacao - -Implement generated `DbDataReader` materialization only as an opt-in/generated path with runtime fallback. Start with statically provable explicit/profile maps and keep `QueryMapped*` runtime annotations until trimmed and Native AOT runtime validation proves a generated-only public path. - -### Relacoes - -Related to FM-RISK-003, FM-RISK-005, FM-RISK-006 and FM-RISK-007. - -## FM-RISK-005 - `QueryMapped*` buffers all rows and has no streaming/unbuffered mode - -**Severidade:** MEDIUM -**Status:** OPEN -**Categoria:** Performance, Memory, Materialization, API Design -**Origem:** Etapa 5 / Entrega 04 -**Detectado em:** implementation and roadmap -**Componentes afetados:** `QueryMappedExtensions.Materialize` - -### Descricao - -`QueryMapped*` reads the entire data reader into a `List` before returning. There is no unbuffered streaming equivalent, which can increase memory pressure for large result sets. - -### Evidencias - -- `docs/sdd/etapa-5/README.md`: lists no streaming/unbuffered support as a main limitation and a P1/P2 follow-up. -- `docs/sdd/etapa-5/04-mapping-profiles.md`: states `QueryMapped*` returns a materialized list and streaming was not implemented. -- `src/Dapper.FluentMap/QueryMappedExtensions.cs`: `Materialize` creates `var results = new List();` and returns it after the reader loop. - -### Cenario de impacto - -A reporting query returns hundreds of thousands of rows with nested Value Objects. `QueryMapped()` stores all rows before the caller can start processing, causing high memory usage. - -### Impacto - -Memory growth, slower first-row availability, and inability to mirror Dapper's unbuffered query behavior for supported nested mappings. - -### Probabilidade - -Media. Large result sets are common, but nested/value-object mapping is opt-in and many use cases will be small projections. - -### Workaround atual - -Use Dapper `Query` for simple mappings, page large result sets manually, or write a custom `DbDataReader` loop for heavy streaming scenarios. - -### Recomendacao - -Design streaming overloads with explicit connection/reader lifetime semantics. Do not expose lazy enumeration over a disposed reader; the API must define ownership clearly. - -### Relacoes - -Related to FM-RISK-004 and the Etapa 5 P1 item for streaming/unbuffered support. - -## FM-RISK-006 - Value Object support excludes factories, private constructors/setters, fields and NRT semantics - -**Severidade:** MEDIUM -**Status:** OPEN -**Categoria:** Value Objects, Materialization, API Design, Extensibility -**Origem:** Etapa 5 / Entrega 03 -**Detectado em:** architecture decision and implementation -**Componentes afetados:** `NestedMaterializationPlan`, `MappingConfigurationValidator`, `QueryMappedExtensions` - -### Descricao - -The current Value Object contract supports public constructors whose parameters can be bound from mapped properties or nested objects. Factory methods, private constructors, private setters, field/backing-field injection, `FormatterServices`, and nullable reference type metadata are intentionally outside the contract. - -### Evidencias - -- `docs/sdd/etapa-5/03-value-objects.md`: explicitly lists factory methods, private constructor/setter, field injection and NRT metadata as out of scope. -- `docs/sdd/etapa-5/decisions.md`: states factory methods require an explicit future API and no private constructor/private setter/field injection support exists. -- `README.md`: says factory methods and generated materializers are not part of the runtime path. -- `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs`: uses public constructors and public setter delegates. -- `test/Dapper.FluentMap.Tests/ValueObjectMaterializationTests.cs`: covers public constructor support and rejection of incomplete/ambiguous constructor scenarios. - -### Cenario de impacto - -A domain model exposes `Cpf.Create(string)` and keeps constructors private to enforce invariants. `QueryMapped*` cannot construct it from `Cpf.Number`, even though the model is common in DDD-style codebases. - -### Impacto - -Consumers must change model visibility, map the whole Value Object through a Dapper `TypeHandler`, or avoid FluentMap-controlled materialization for that shape. - -### Probabilidade - -Media. Public-constructor Value Objects are supported, but private factories are common enough in domain models. - -### Workaround atual - -Use a public constructor, use a Dapper `TypeHandler` when the whole Value Object maps to one column, or materialize manually. - -### Recomendacao - -Design a strongly typed factory API with deterministic ambiguity rules and validation. Do not infer factories by name or reflection convention. - -### Relacoes - -Related to FM-RISK-004, FM-RISK-007 and Etapa 5 P2 factory-method follow-up. - -## FM-RISK-007 - Dapper TypeHandler integration in the runtime materializer depends on reflective access to `SqlMapper.TypeHandlerCache` - -**Severidade:** MEDIUM -**Status:** MITIGATED -**Categoria:** Compatibility, Reflection, Value Objects, Maintainability -**Origem:** Etapa 5 / Entrega 03 -**Detectado em:** implementation review -**Componentes afetados:** `DapperTypeHandlerAdapter`, `NestedMaterializationPlan` - -### Descricao - -The runtime materializer detects a Dapper type handler with `SqlMapper.HasTypeHandler`. Dapper `2.1.79` does not expose a public API to convert one arbitrary `object` through the registered handler, so FluentMap still calls Dapper's nested `TypeHandlerCache.Parse` using reflection. The reflection is now isolated behind an internal compatibility adapter instead of living in the materialization plan. - -### Evidencias - -- `docs/sdd/etapa-5/01-nested-materialization-spike.md`: records that conversions should respect TypeHandlers without copying Dapper internals. -- `docs/sdd/etapa-5/03-value-objects.md`: states TypeHandler support is preserved for scalar Value Object properties. -- `src/Dapper.FluentMap/Compatibility/DapperTypeHandlerAdapter.cs`: centralizes `TypeHandlerCache.Parse(object)` reflection and fails with `FluentMapConfigurationException` if the expected Dapper shape is missing. -- `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs`: delegates TypeHandler detection/invocation to `DapperTypeHandlerAdapter`. -- `test/Dapper.FluentMap.Tests/DapperCompatibilityAdapterTests.cs`: verifies registered handler conversion, nullable handler null semantics, no-handler fallback and diagnostic failure when the compatibility boundary cannot resolve the Dapper cache shape. -- `test/Dapper.FluentMap.Tests/ValueObjectMaterializationTests.cs`: continues to verify `QueryMappedShouldUseDapperTypeHandlerForScalarValueObjectProperty`. -- `src/Dapper.FluentMap/Dapper.FluentMap.csproj`: Dapper is pinned to `2.1.79`, reducing immediate drift. - -### Cenario de impacto - -A future Dapper update removes, renames or changes `TypeHandlerCache.Parse`. FluentMap still sees `HasTypeHandler == true`, but the adapter cannot call the handler through this reflective path and throws a diagnostic compatibility exception during plan creation. - -### Impacto - -Potential regression in scalar Value Object handling under `QueryMapped*` after Dapper upgrades. - -### Probabilidade - -Baixa. The current dependency is pinned and covered by tests, but the risk rises during dependency updates. - -### Workaround atual - -Keep Dapper upgrade tasks isolated and run `DapperCompatibilityAdapterTests` plus `ValueObjectMaterializationTests`. Consumers can use Dapper `Query` for scalar TypeHandler paths outside `QueryMapped*`. - -### Recomendacao - -Investigate a public Dapper-supported handler invocation path during future Dapper upgrades. If none exists, keep the adapter small, fail diagnosticably and do not spread reflection into materialization code. - -### Relacoes - -Related to FM-RISK-004 and any future Dapper dependency update. - -## FM-RISK-008 - Mapping profiles do not support per-profile conventions/naming policies - -**Severidade:** MEDIUM -**Status:** OPEN -**Categoria:** Profiles, Extensibility, API Design, Mapping -**Origem:** Etapa 5 / Entrega 04 -**Detectado em:** architecture decision and roadmap -**Componentes afetados:** `MappingRegistry.ProfileMaps`, `TypeConventions`, profile query path - -### Descricao - -Profiles are query-scoped and can define explicit maps, but conventions and naming policies are still registered by entity. The profile path applies entity-level conventions read-only; it cannot define conventions scoped only to one profile. - -### Evidencias - -- `docs/sdd/etapa-5/04-mapping-profiles.md`: explicitly defers per-profile conventions/naming policies. -- `docs/sdd/etapa-5/decisions.md`: says conventions/naming policies continue by entity and per-profile conventions are future debt. -- `docs/sdd/etapa-5/README.md`: P1 item to define per-profile conventions/naming policies before expanding policy composition. -- `src/Dapper.FluentMap/MappingRegistry.cs`: stores default/profile maps separately, but conventions remain `ConcurrentDictionary> TypeConventions`. -- `test/Dapper.FluentMap.Tests/MappingProfileTests.cs`: validates entity naming policy applied to a profile, not per-profile policy registration. - -### Cenario de impacto - -The same `Customer` entity has one legacy profile using `legacy_customer_id` and a reporting profile using `report_customer_id`. The consumer wants a prefix policy per profile but must map each property explicitly. - -### Impacto - -More boilerplate and higher maintenance cost for profiles with broad naming differences. - -### Probabilidade - -Media. Profiles exist exactly to support different SQL shapes, and naming conventions often vary between systems. - -### Workaround atual - -Use explicit mappings inside each profile map. - -### Recomendacao - -Design profile-scoped convention storage and precedence rules before adding APIs. Ensure defaults do not leak into profiles silently except where explicitly documented. - -### Relacoes - -Related to FM-RISK-009 and the profile decisions in Etapa 5. - -## FM-RISK-009 - Mapping profiles do not apply to `Dapper.Query` or Dapper multi-mapping - -**Severidade:** MEDIUM -**Status:** OPEN -**Categoria:** Profiles, Materialization, API Design, Compatibility -**Origem:** Etapa 5 / Entrega 04 -**Detectado em:** architecture decision -**Componentes afetados:** `QueryMappedExtensions`, `MappingRegistry`, Dapper integration - -### Descricao - -Profiles are intentionally available only through `QueryMapped()` and related opt-in APIs. `Dapper.Query()` continues to use the default mapping, and Dapper multi-mapping has no profile overload. - -### Evidencias - -- `docs/sdd/etapa-5/04-mapping-profiles.md`: rejects mutation-scope profiles through `SqlMapper.SetTypeMap` and states profiles do not apply to `Dapper.Query` or multi-mapping. -- `docs/sdd/etapa-5/decisions.md`: says multiple profiles per type are supported only in `QueryMapped*`. -- `README.md`: documents that `Dapper.Query` and `QueryMapped` use default mapping; profile selection is tied to `QueryMapped()`. -- `test/Dapper.FluentMap.Tests/MappingProfileTests.cs`: verifies `DapperQueryShouldContinueUsingDefaultMapping`. - -### Cenario de impacto - -A consumer uses Dapper multi-mapping to compose aggregates and wants the second object to use a profile. FluentMap has no query-scoped hook for Dapper's multi-mapping API. - -### Impacto - -Profiles cannot cover some common Dapper query patterns. Consumers must choose between custom callbacks/manual mapping and `QueryMapped*` limitations. - -### Probabilidade - -Media. Dapper multi-mapping is a common advanced feature, but profile support is explicitly opt-in and new. - -### Workaround atual - -Use `QueryMapped()` for single-entity materialization or manual Dapper multi-mapping callbacks for multi-entity composition. - -### Recomendacao - -Track demand before expanding the API. If implemented, avoid temporary `SqlMapper.SetTypeMap` swaps; use an operation-scoped materializer or wait for a public Dapper hook. - -### Relacoes - -Related to FM-RISK-001, FM-RISK-008 and Etapa 5 rejected alternative "Mutation scope". - -## FM-RISK-010 - Legacy `ApplyMapsFromAssemblies` keeps older reflection/discovery behavior - -**Severidade:** MEDIUM -**Status:** MITIGATED -**Categoria:** Reflection, Compatibility, Maintainability, Developer Experience -**Origem:** Etapa 2 / Entrega 02; Etapa 3 / Entrega 01 -**Detectado em:** discovery/reflection review -**Componentes afetados:** `FluentMapConfigurationExtensions.ApplyMapsFromAssemblies` - -### Descricao - -The modern scanning APIs are deterministic and integrated into the registry, but the legacy `ApplyMapsFromAssemblies` remains for compatibility. It still uses `Assembly.GetTypes()`, reflection invocation and `Activator.CreateInstance`, and earlier SDD notes left its discovery diagnostics outside functional redesign. - -### Evidencias - -- `docs/sdd/etapa-2/02-configuration-validation.md`: keeps `FluentMapConfigurationExtensions.ApplyMapsFromAssemblies` discovery/reflection diagnostics outside scope. -- `docs/sdd/etapa-3/01-mapping-registration.md`: preserves `ApplyMapsFromAssemblies(...)` for compatibility and adds modern alternatives. -- `docs/sdd/etapa-4/02-trimming-aot.md`: marks the legacy API as trimming-sensitive. -- `src/Dapper.FluentMap/Utils/FluentMapConfigurationExtensions.cs`: uses `GetTypes()`, `MakeGenericMethod`, `Invoke`, `Activator.CreateInstance`, and throws `InvalidOperationException` for duplicate mappings. - -### Cenario de impacto - -A legacy consumer scans assemblies with maps that include base maps or contain problematic types. The legacy path can produce reflection-shaped errors and lacks the same documented deterministic preflight behavior as the modern API. - -### Impacto - -Diagnostics and ordering may be less predictable than modern registration APIs, and trimming/AOT behavior is fragile. - -### Probabilidade - -Baixa. Modern APIs and generator are documented, but legacy consumers can still call this public extension. - -### Workaround atual - -Use `AddMap()`, `AddMapsFromAssembly(...)`, `AddMapsFromAssemblyContaining()`, or `AddGeneratedMappings()`. - -### Recomendacao - -Document `ApplyMapsFromAssemblies` as legacy. In a future major version, consider deprecation or routing it through the same modern scanning implementation if behavior can be preserved. - -### Relacoes - -Related to FM-RISK-003 and Etapa 3 registration decisions. - -## FM-RISK-011 - Constructor overload ambiguity and optional parameters remain delegated to Dapper - -**Severidade:** MEDIUM -**Status:** OPEN -**Categoria:** Materialization, Correctness, Compatibility -**Origem:** Etapa 3 / Entrega 02 -**Detectado em:** architecture decision and tests -**Componentes afetados:** `FluentConstructorTypeMap`, `MultiTypeMap`, Dapper `DefaultTypeMap` - -### Descricao - -FluentMap translates configured column metadata to Dapper for simple constructor mapping, but it does not own root constructor selection ambiguity, optional parameter behavior or Dapper's underscore matching flag. These remain governed by Dapper. - -### Evidencias - -- `docs/sdd/etapa-3/02-constructor-immutable-mapping.md`: states constructor overload ambiguity remains Dapper responsibility and optional parameters receive no special handling. -- `docs/sdd/etapa-3/decisions.md`: says constructor selection remains delegated to `DefaultTypeMap`. -- `src/Dapper.FluentMap/TypeMaps/FluentConstructorTypeMap.cs`: delegates constructor matching to Dapper `DefaultTypeMap` after translating names/types. -- `test/Dapper.FluentMap.Tests/ConstructorMappingTests.cs`: covers supported constructor scenarios, including that nested `MemberPath` is not root constructor mapping. - -### Cenario de impacto - -A model has multiple public constructors that Dapper can interpret similarly after FluentMap translates column names. Dapper chooses according to its own rules, or fails, and FluentMap does not add a separate diagnostic layer for that root constructor ambiguity. - -### Impacto - -Behavior can surprise consumers who expect FluentMap's diagnostics to cover all immutable-constructor edge cases. - -### Probabilidade - -Baixa. Common single-constructor and record cases are tested, and ambiguous public constructors are less common. - -### Workaround atual - -Keep materialized entities constructor shapes simple, avoid ambiguous overloads, and use `QueryMapped*` for nested immutable graphs where FluentMap has its own constructor plan. - -### Recomendacao - -Do not reimplement Dapper constructor selection casually. If demand appears, add narrow diagnostics that explain Dapper-delegated ambiguity without changing behavior. - -### Relacoes - -Related to FM-RISK-004 and FM-RISK-006. - -## FM-RISK-012 - Throwing `IgnoredPropertyInfo` sentinel was removed from ignored/nested mapping paths - -**Severidade:** MEDIUM -**Status:** RESOLVED -**Categoria:** Correctness, Maintainability, Technical Debt -**Origem:** Etapa 2 / Entrega 02; Etapa 6 / Entrega 03 -**Detectado em:** implementation review -**Componentes afetados:** `DapperIgnoredMemberMap`, `DapperFluentPropertyTypeMap`, `MultiTypeMap` - -### Descricao - -Ignored and nested mappings previously used an internal `IgnoredPropertyInfo` sentinel to prevent Dapper fallback. The sentinel overrode many `PropertyInfo` members by throwing `NotImplementedException`. Etapa 6 / Entrega 03 removed that sentinel and replaced it with an explicit internal `IMemberMap` marker that has safe null members. - -### Evidencias - -- `docs/sdd/etapa-2/02-configuration-validation.md`: catalogs `IgnoredPropertyInfo` throwing `NotImplementedException` as partially detectable and outside the delivery scope. -- `src/Dapper.FluentMap/TypeMaps/IgnoredPropertyInfo.cs`: removed. -- `src/Dapper.FluentMap/Compatibility/DapperFluentPropertyTypeMap.cs`: returns `DapperIgnoredMemberMap` for ignored mappings and FluentMap-controlled nested paths. -- `src/Dapper.FluentMap/Compatibility/DapperIgnoredMemberMap.cs`: implements `SqlMapper.IMemberMap` without throwing `PropertyInfo` members. -- `src/Dapper.FluentMap/TypeMaps/MultiTypeMap.cs`: recognizes `DapperIgnoredMemberMap` and returns `null` without falling back to `DefaultTypeMap`. -- `test/Dapper.FluentMap.Tests/DapperCompatibilityAdapterTests.cs`: verifies ignored root mapping, ignored nested path, Dapper fallback for unrelated members and direct type-map access without `NotImplementedException`. - -### Cenario de impacto - -Previously, a future Dapper version could inspect more of the returned `PropertyInfo` before FluentMap intercepted it. That path no longer exists because ignored/nested markers no longer expose a throwing `PropertyInfo`. - -### Impacto - -Resolved for the known sentinel path. Ignored/nested mappings now use an explicit internal `IMemberMap` marker. - -### Probabilidade - -Baixa for future Dapper fallback behavior, but the specific `NotImplementedException` sentinel risk is resolved. - -### Workaround atual - -None needed for this issue. - -### Recomendacao - -Keep `DapperCompatibilityAdapterTests` in the Dapper upgrade checklist to verify ignored mappings still block fallback. - -### Relacoes - -Related to FM-RISK-007 and future Dapper compatibility work, but no longer tracked as active technical debt. - -## FM-RISK-013 - Dommel behavior for profiles/nested materialization is intentionally unreviewed - -**Severidade:** MEDIUM -**Status:** UNKNOWN -**Categoria:** Extensibility, Profiles, Mapping, Documentation -**Origem:** Etapa 5 / Entrega 04 -**Detectado em:** roadmap/research item -**Componentes afetados:** `Dapper.FluentMap.Dommel`, default/profile mapping registry - -### Descricao - -Etapa 5 intentionally did not change Dommel. Profiles and nested materialization are core/query-wrapper features, while Dommel still consumes the historical mapping surfaces. The SDD explicitly asks for a future Dommel review to decide whether profiles should be visible to external CRUD integrations. - -### Evidencias - -- `docs/sdd/etapa-5/README.md`: says Dommel received no functional changes and lists "Revisar Dommel em etapa propria" under research. -- `docs/sdd/etapa-5/decisions.md`: profiles affect `QueryMapped*`; Dapper global type map represents only default mapping. -- `src/Dapper.FluentMap.Dommel/Resolvers/*`: Dommel resolver implementation remains separate. -- `test/Dapper.FluentMap.Dommel.Tests/ManualMappingTests.cs`: Dommel tests cover legacy resolver behavior only. - -### Cenario de impacto - -A consumer expects a Dommel CRUD operation to use a profile map registered by `AddProfile()`. Current evidence indicates profiles are query-scoped to `QueryMapped*`, but Dommel-specific behavior has not been formally reviewed for this new model. - -### Impacto - -Potential documentation/support confusion and extension limitations for Dommel consumers. - -### Probabilidade - -Baixa. Dommel profile integration is not documented as supported, but profile adoption can create expectations. - -### Workaround atual - -Use default maps for Dommel and reserve profiles for `QueryMapped()`. - -### Recomendacao - -Run a dedicated Dommel design/review stage. Decide whether profiles should remain invisible to Dommel or receive explicit APIs, and document the outcome. - -### Relacoes - -Related to FM-RISK-001, FM-RISK-002, FM-RISK-008 and FM-RISK-009. - -## 7. Low Risks - -## FM-RISK-014 - Analyzer and generator coverage is intentionally partial - -**Severidade:** LOW -**Status:** MITIGATED -**Categoria:** Developer Experience, Testing, Maintainability -**Origem:** Etapa 4 / Entregas 01 and 03; Etapa 5 / Entrega 04 -**Detectado em:** analyzer/generator design decisions -**Componentes afetados:** `Dapper.FluentMap.Analyzers`, `Dapper.FluentMap.Generators` - -### Descricao - -The analyzer and generator detect only statically provable cases. They do not execute map constructors, follow helper methods, simulate scanning, reason about dynamic columns, or prove query-specific materialization validity. - -### Evidencias - -- `docs/sdd/etapa-4/README.md`: runtime remains authority; do not report what cannot be proven statically. -- `docs/sdd/etapa-4/01-roslyn-analyzers.md`: lists many analyzer limitations. -- `docs/sdd/etapa-4/03-source-generator.md`: generator discovers only maps declared in the current compilation. -- `src/Dapper.FluentMap.Analyzers/README.md`: analyzer complements runtime validation and does not replace it. -- `src/Dapper.FluentMap.Generators/README.md`: generator emits registration only for eligible maps in current compilation. - -### Cenario de impacto - -A consumer moves mapping calls into helper methods or uses dynamically computed column names. The analyzer stays silent, and invalid configuration is caught only by runtime validation or query execution. - -### Impacto - -Lower compile-time feedback coverage than consumers might assume. - -### Probabilidade - -Alta. Helper methods and dynamic configuration are common, but the README and SDD make runtime authority clear. - -### Workaround atual - -Call `FluentMapper.Validate()` during startup/tests and keep runtime fail-fast validations enabled. - -### Recomendacao - -Improve analyzer coverage only for patterns that can be proven without false positives. Add documentation examples that pair analyzer use with startup validation. - -### Relacoes - -Related to FM-RISK-003 and FM-RISK-010. - -## FM-RISK-015 - Async `QueryMapped*` overloads are asymmetric: profile async exists, default async does not - -**Severidade:** LOW -**Status:** OPEN -**Categoria:** API Design, Developer Experience -**Origem:** Etapa 5 / Entrega 04 -**Detectado em:** implementation and roadmap -**Componentes afetados:** `QueryMappedExtensions` - -### Descricao - -The current public API includes async overloads for profile queries but not equivalent default `QueryMappedAsync()` and `QueryMappedSingleAsync()` overloads. The SDD lists symmetric async/default overload expansion as a future demand-driven item. - -### Evidencias - -- `docs/sdd/etapa-5/README.md`: P2 item to expand async/default overloads symmetrically if public demand appears. -- `src/Dapper.FluentMap/QueryMappedExtensions.cs`: async methods are present for `` only. -- `test/Dapper.FluentMap.Tests/MappingProfileTests.cs`: validates async concurrent profile queries. - -### Cenario de impacto - -A consumer using default nested mappings in an async data-access layer cannot call a default `QueryMappedAsync()` API. - -### Impacto - -Ergonomic limitation; not a correctness bug. - -### Probabilidade - -Media. Async data access is common, but profile async was prioritized for concurrency validation. - -### Workaround atual - -Use sync `QueryMapped()` for default mappings or introduce an explicit profile when async profile APIs are acceptable. - -### Recomendacao - -Add symmetric default async overloads in a small API-only delivery with integration tests and cancellation-token coverage through `CommandDefinition`. - -### Relacoes - -Related to FM-RISK-005 and Etapa 5 API evolution. - -## FM-RISK-016 - NuGet package metadata remains legacy - -**Severidade:** LOW -**Status:** OPEN -**Categoria:** Documentation, Developer Experience, Packaging, Maintainability -**Origem:** .NET 10 / Entrega 04; Security hardening -**Detectado em:** package validation -**Componentes afetados:** `src/Dapper.FluentMap/*.csproj`, package output - -### Descricao - -Package metadata still uses `PackageLicenseUrl` and does not include a package README, SourceLink or repository metadata modernization. Pack succeeds, but NuGet emits NU5125 and README recommendations. - -### Evidencias - -- `docs/sdd/net10-migration/04-validation-pack-ci.md`: defers metadata modernization. -- `docs/sdd/net10-migration/README.md`: lists metadata modernization as out of scope. -- `docs/sdd/security-hardening/sqlitepclraw-vulnerability.md`: pack succeeds with existing NU5125 and README recommendation. -- `src/Dapper.FluentMap/Dapper.FluentMap.csproj`: contains `PackageLicenseUrl`. -- `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj`: contains `PackageLicenseUrl`. - -### Cenario de impacto - -A package consumer or NuGet UI sees older metadata conventions and missing README even though the package builds correctly. - -### Impacto - -Lower package polish and possible future NuGet warning churn, but no runtime behavior impact. - -### Probabilidade - -Alta. The warning is repeatedly observed during pack. - -### Workaround atual - -None needed for runtime. Treat pack warnings as known until a metadata-only cleanup is scheduled. - -### Recomendacao - -Run a dedicated packaging modernization: replace `PackageLicenseUrl` with `PackageLicenseExpression`, add package README/repository metadata, inspect `.nupkg`, and keep it separate from functional changes. - -### Relacoes - -Related to `.NET 10` migration packaging decisions. - -## FM-RISK-017 - Remote CI execution remains unproven after CI modernization - -**Severidade:** LOW -**Status:** UNKNOWN -**Categoria:** Testing, Maintainability, Developer Experience -**Origem:** .NET 10 / Entregas 04 and 05 -**Detectado em:** validation limitation -**Componentes afetados:** `.github/workflows/ci.yml`, `.appveyor.yml`, `.travis.yml` - -### Descricao - -CI files were updated and locally reviewed, but GitHub Actions, AppVeyor and Travis were not executed remotely from the development environment. - -### Evidencias - -- `docs/sdd/net10-migration/04-validation-pack-ci.md`: remote GitHub Actions, AppVeyor and Travis runs were not executed; Travis availability/image contents unproven. -- `docs/sdd/net10-migration/05-xunit3-migration.md`: CI files reviewed after xUnit 3 migration, but no remote execution evidence. -- `.github/workflows/ci.yml`, `.appveyor.yml`, `.travis.yml`: current CI definitions. - -### Cenario de impacto - -A push triggers CI and discovers that a hosted image, action version, Travis environment, or .NET 10 installation path behaves differently from local validation. - -### Impacto - -CI failure after merge/push, with no evidence of runtime library defect. - -### Probabilidade - -Media. Local command validation is strong, but remote infrastructure can drift. - -### Workaround atual - -Run remote CI before release decisions and treat local validation as necessary but not sufficient. - -### Recomendacao - -After the next push, record actual CI results in the SDD status or release notes. Revisit Travis if the service no longer supports the expected .NET 10 workflow. - -### Relacoes - -Related to `.NET 10` migration validation. - -## FM-RISK-018 - Documentation carries archived/legacy signals alongside new SDD features - -**Severidade:** LOW -**Status:** OPEN -**Categoria:** Documentation, Developer Experience, Maintainability -**Origem:** README and accumulated SDD updates -**Detectado em:** documentation review -**Componentes afetados:** `README.md`, CI badges, consumer-facing docs - -### Descricao - -The README still begins with an archived-project notice and historical CI badges, while later sections document new SDD-era capabilities such as nested materialization, profiles, analyzers, generators and .NET 10 validation. This can confuse readers about maintenance status and supported feature freshness. - -### Evidencias - -- `README.md`: starts with an "Archived" notice from the original project. -- `README.md`: later contains sections for nested object materialization, mapping profiles, trimming/Native AOT, generated registration and Etapa summaries. -- `.github/workflows/ci.yml`: new CI exists, while README badges still point to older AppVeyor/Travis-era links. -- `docs/sdd/etapa-5/README.md`: documents current supported capabilities and limitations. - -### Cenario de impacto - -A consumer reads the top of README, assumes the project is abandoned, then sees modern features and cannot tell which status is authoritative. - -### Impacto - -Documentation trust and adoption risk, not a code correctness risk. - -### Probabilidade - -Media. README is the primary entry point. - -### Workaround atual - -Use SDD reports and current tests as source of truth for recent work. - -### Recomendacao - -Create a documentation-only decision about project status. Either preserve the archived notice as historical context with a current-maintenance note, or move it to an archival/history section. - -### Relacoes - -Related to FM-RISK-016. - -## 8. Cross-Cutting Architectural Concerns - -- Global/static state: `FluentMapper`, Dapper type maps, test reset and Dommel resolver integration remain the main cross-cutting constraint. -- Reflection: expression parsing, assembly scanning, diagnostics, registration inference and runtime materialization all depend on metadata to different degrees. -- Caching: property-map and materialization-plan caches are structured and include profile/column shape where relevant, but cache invalidation still assumes registry-mediated mutation. -- Materialization: `Dapper.Query` remains Dapper-owned for simple mappings; nested/value-object/profile behavior is opt-in through `QueryMapped*`. -- AOT/trimming: explicit/generated registration is the safer path; scanning and `QueryMapped*` remain annotated as sensitive. -- Profiles: query-scoped profiles avoid global `SetTypeMap` swaps, but do not yet cover Dapper multi-mapping, `Dapper.Query` or per-profile conventions. -- Dommel: left stable by design, but not reviewed against the new profile/nested materialization model. - -## 9. Technical Debt Register - -| ID | Debt | Origin | Impact | Suggested Priority | -| -- | ---- | ------ | ------ | ------------------ | -| FM-RISK-001 | Global/static state and disabled test parallelism | Etapa 1 | Blocks stronger concurrency guarantees | P1 | -| FM-RISK-002 | Public mutable mapping dictionaries | Etapa 1 | Can bypass validation/cache invalidation | P1 | -| FM-RISK-004 | No generated `DbDataReader` materializer | Etapa 5 | AOT/performance limitation | P1 | -| FM-RISK-005 | No streaming/unbuffered `QueryMapped*` | Etapa 5 | Memory/performance limitation | P1 | -| FM-RISK-008 | No per-profile conventions/naming policies | Etapa 5 | Boilerplate and profile extensibility limit | P1 | -| FM-RISK-006 | No Value Object factory API | Etapa 5 | Common domain model limitation | P2 | -| FM-RISK-009 | No profile integration for Dapper multi-mapping | Etapa 5 | Advanced Dapper scenarios uncovered | P2 | -| FM-RISK-015 | Missing default async `QueryMapped*` overloads | Etapa 5 | API ergonomics | P2 | -| FM-RISK-016 | Legacy NuGet metadata | .NET 10 migration | Package polish/warnings | P2 | -| FM-RISK-018 | README maintenance-status inconsistency | README/SDD | Consumer confusion | P2 | -| FM-RISK-017 | Remote CI evidence missing | .NET 10 migration | Release confidence | P2 | -| FM-RISK-010 | Legacy assembly scanning API behavior | Etapa 3 | Maintenance/diagnostic debt | P3 | -| FM-RISK-014 | Partial analyzer/generator coverage | Etapa 4 | Compile-time feedback gaps | P3 | -| FM-RISK-011 | Dapper-delegated constructor edge cases | Etapa 3 | Edge-case diagnostics | P3 | -| FM-RISK-013 | Dommel profile/nested review missing | Etapa 5 | Extension clarity | P3 | -| FM-RISK-007 | Reflective TypeHandler adapter | Etapa 5 | Dapper upgrade fragility | P3 | -| FM-RISK-003 | Scanning unsafe under trimming/AOT | Etapa 4 | Compatibility risk if warnings ignored | P3, unless AOT-focused release | - -## 10. Historical Issues Already Resolved - -| Problem | Origin | Resolved In | Evidence | -| ------- | ------ | ----------- | -------- | -| ReflectionHelper could resolve a homonymous method/member instead of the expression property | Etapa 1 / Entrega 01 | Etapa 1 / Entrega 01 | `docs/sdd/etapa-1/01-reflection-helper.md`; `src/Dapper.FluentMap/Utils/ReflectionHelper.cs`; `test/Dapper.FluentMap.Tests/ReflectionHelperTests.cs` | -| Explicit mapping and convention order caused "last SetTypeMap wins" behavior | Etapa 1 / Entrega 02 | Etapa 1 / Entrega 02 and 04 | `docs/sdd/etapa-1/02-mapping-composition.md`; `src/Dapper.FluentMap/MappingRegistry.cs`; `test/Dapper.FluentMap.Tests/MappingCompositionTests.cs` | -| Old string-concatenated mapping cache could retain stale/mis-keyed hits/misses | Etapa 1 / Entrega 03 | Etapa 1 / Entrega 04 | `docs/sdd/etapa-1/04-mapping-registry-cache.md`; `src/Dapper.FluentMap/MappingCacheKey.cs`; `test/Dapper.FluentMap.Tests/MappingRegistryTests.cs` | -| No atomic internal reset for tests | Etapa 1 / Entrega 03 | Etapa 1 / Entrega 04 | `docs/sdd/etapa-1/04-mapping-registry-cache.md`; `src/Dapper.FluentMap/MappingRegistry.cs` | -| Nested paths with same terminal name, such as `Rank.Level` and `Seniority.Level`, were treated as duplicate | Etapa 2 / Entrega 01 | Etapa 2 / Entrega 01 | `docs/sdd/etapa-2/01-member-path.md`; `src/Dapper.FluentMap/Mapping/MemberPath.cs`; `test/Dapper.FluentMap.Tests/MemberPathTests.cs` | -| Configuration errors used generic/late exceptions for many invalid mappings | Etapa 2 / Entrega 02 | Etapa 2 / Entrega 02 and Etapa 3 / Entrega 03 | `docs/sdd/etapa-2/02-configuration-validation.md`; `src/Dapper.FluentMap/FluentMapConfigurationException.cs`; `src/Dapper.FluentMap/MappingConfigurationValidator.cs`; `FluentMapper.Validate()` | -| No inherited mapping composition | Etapa 2 / Entrega 03 | Etapa 2 / Entrega 03 | `docs/sdd/etapa-2/03-inherited-mappings.md`; `test/Dapper.FluentMap.Tests/InheritedMappingTests.cs` | -| No declarative naming policy API | Etapa 2 / Entrega 04 | Etapa 2 / Entrega 04 | `docs/sdd/etapa-2/04-naming-policies.md`; `src/Dapper.FluentMap/Naming/NamingPolicy.cs`; `test/Dapper.FluentMap.Tests/NamingPolicyTests.cs` | -| No explicit generic registration or deterministic modern assembly scanning | Etapa 3 / Entrega 01 | Etapa 3 / Entrega 01 | `docs/sdd/etapa-3/01-mapping-registration.md`; `test/Dapper.FluentMap.Tests/MappingRegistrationTests.cs` | -| Mapped columns did not influence Dapper constructor mapping for immutable simple models | Etapa 3 / Entrega 02 | Etapa 3 / Entrega 02 | `docs/sdd/etapa-3/02-constructor-immutable-mapping.md`; `src/Dapper.FluentMap/TypeMaps/FluentConstructorTypeMap.cs`; `test/Dapper.FluentMap.Tests/ConstructorMappingTests.cs` | -| No public aggregate validation/explain diagnostics | Etapa 2 and Etapa 3 | Etapa 3 / Entrega 03 | `docs/sdd/etapa-3/03-diagnostics-api.md`; `src/Dapper.FluentMap/Diagnostics/*`; `test/Dapper.FluentMap.Tests/DiagnosticsApiTests.cs` | -| Runtime registration created type maps via `MakeGenericType` and `Activator.CreateInstance` | Etapa 3 / Entrega 01 | Etapa 4 / Entrega 02 | `docs/sdd/etapa-4/02-trimming-aot.md`; `src/Dapper.FluentMap/MappingRegistry.cs` | -| No source generator for mapping registration | Etapa 4 planning | Etapa 4 / Entrega 03 | `docs/sdd/etapa-4/03-source-generator.md`; `src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs` | -| Nested paths returned to Dapper could write leaf values into the root object slot | Etapa 5 / Entrega 01 | Etapa 5 / Entrega 02 | `docs/sdd/etapa-5/01-nested-materialization-spike.md`; `docs/sdd/etapa-5/02-nested-object-materialization.md`; `test/Dapper.FluentMap.Tests/NestedMaterializationSpikeTests.cs` | -| Mutable nested object materialization was unsupported | Etapa 5 / Entrega 01 | Etapa 5 / Entrega 02 | `docs/sdd/etapa-5/02-nested-object-materialization.md`; `test/Dapper.FluentMap.Tests/NestedObjectMaterializationTests.cs` | -| Immutable nested Value Objects were unsupported | Etapa 5 / Entrega 01-02 | Etapa 5 / Entrega 03 | `docs/sdd/etapa-5/03-value-objects.md`; `test/Dapper.FluentMap.Tests/ValueObjectMaterializationTests.cs` | -| Same entity could not have multiple query-scoped mapping profiles | Etapa 3 limitations | Etapa 5 / Entrega 04 | `docs/sdd/etapa-5/04-mapping-profiles.md`; `test/Dapper.FluentMap.Tests/MappingProfileTests.cs` | -| Tests targeted obsolete `netcoreapp3.1` and could not run on the local machine | .NET 10 / Entrega 01 | .NET 10 / Entrega 02 | `docs/sdd/net10-migration/01-inventory-baseline.md`; `docs/sdd/net10-migration/02-test-projects-net10.md` | -| xUnit 2 was deprecated/legacy in package diagnostics | .NET 10 / Entrega 01-04 | .NET 10 / Entrega 05 | `docs/sdd/net10-migration/05-xunit3-migration.md`; test `.csproj` files | -| Vulnerable test transitive `SQLitePCLRaw.lib.e_sqlite3 2.1.11` | .NET 10 migration | Security hardening | `docs/sdd/security-hardening/sqlitepclraw-vulnerability.md`; `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | - -## 11. Unknown / Requires Investigation - -- FM-RISK-013: Dommel interaction with profiles/nested materialization requires a dedicated review. Current evidence only proves legacy/default Dommel behavior. -- FM-RISK-017: CI needs actual remote execution evidence after modernization. -- Native AOT full runtime behavior remains unproven locally because the platform linker was missing during Etapa 4 and Etapa 5 validation. -- Current external action/service versions were not verified during this audit; the report relies on repository evidence, not live CI execution. - -## 12. Recommended Remediation Order - -1. Document and constrain the configuration lifecycle around global/static state before changing implementation. This reduces ambiguity for FM-RISK-001 and FM-RISK-002. -2. Plan a compatibility-safe path away from public mutable dictionaries, likely with read-only views and a future-major migration note. -3. Implement a generated `DbDataReader` materializer, because it unlocks the biggest cluster: FM-RISK-004, FM-RISK-005 and part of FM-RISK-007. -4. Add streaming/unbuffered `QueryMapped*` only after reader lifetime semantics are designed. -5. Design per-profile conventions/naming policies before expanding profile APIs further. -6. Add default async `QueryMapped*` overloads with `CommandDefinition`/cancellation coverage. -7. Run a dedicated Dommel profile/nested review and document whether integration is intentionally unsupported. -8. Modernize NuGet metadata and README status in a documentation/packaging-only delivery. -9. Record remote CI outcomes after the next push. -10. Revisit lower-level compatibility debt: reflective TypeHandler adapter and legacy scanning API. - -## 13. Architectural Health Assessment - -### Strengths - -- The project has unusually strong SDD traceability for a small library. -- Public behavior is protected by integration tests using real Dapper and SQLite. -- Precedence is explicit and repeatedly validated: explicit, inherited explicit, convention/naming policy, Dapper default. -- `MemberPath` removed a class of reflection/name-collision bugs and made nested/profiles possible. -- Runtime validation remains authoritative even after analyzers and generator were added. -- Profiles avoid unsafe temporary `SqlMapper.SetTypeMap` mutation by using query-scoped materialization. -- Published source projects remain `netstandard2.0`, preserving broad compatibility. - -### Concerns - -- Global mutable state and public mutable dictionaries remain the central architectural debt. -- AOT/trimming support is split: explicit/generated registration is good, scanning and `QueryMapped*` remain constrained. -- The runtime materializer has real scope but currently lacks generated and streaming variants. -- Dommel was intentionally not evolved with the core profile/nested model. -- README/package metadata still carry legacy signals. - -### Evolution Risks - -- Adding per-profile conventions, streaming, factory methods or generated materializers will touch shared registry/materialization/cache contracts. -- Removing or hiding public dictionaries would be a compatibility-sensitive major-version decision. -- Future Dapper updates need targeted review around `ITypeMap`, `IMemberMap`, constructor mapping and TypeHandler internals. -- Expanding Dommel support could reintroduce global state concerns if it tries to observe profiles through Dapper's global type-map path. - -### Overall Assessment - -**Moderate technical risk** - -The core design is coherent and much healthier than the historical baseline: the main correctness bugs around expression resolution, mapping composition, cache keys, nested path identity and profile concurrency have been addressed. The remaining risk is moderate because the library still carries global mutable compatibility surfaces and the newest materialization capabilities depend on runtime reflection/dynamic code. There is no evidence of a current critical production-unsafety condition when the documented startup-once and opt-in APIs are used. diff --git a/docs/sdd/net10-migration/01-inventory-baseline.md b/docs/sdd/net10-migration/01-inventory-baseline.md deleted file mode 100644 index 1ebb331..0000000 --- a/docs/sdd/net10-migration/01-inventory-baseline.md +++ /dev/null @@ -1,288 +0,0 @@ -# 01 - Inventory and Baseline - -## Specification - -This delivery inventories the current state before any migration or dependency update. - -Target final state: - -```text -src/Dapper.FluentMap -> netstandard2.0 -src/Dapper.FluentMap.Dommel -> netstandard2.0 -test/Dapper.FluentMap.Tests -> net10.0 -test/Dapper.FluentMap.Dommel.Tests -> net10.0 -``` - -Rules for this delivery: - -- Do not change `TargetFramework` or `TargetFrameworks`. -- Do not update packages. -- Do not change C# code, solution files, CI, pack files, or workflows. -- Create only SDD handoff documentation under `docs/sdd/net10-migration/`. -- Preserve `netstandard2.0` for all `src/` projects. - -## Discovery - -### Branch - -Current shared branch: - -- `chore/net10-migration` - -This branch was created locally for the migration because the starting branch was not `main`/`master` and `chore/net10-migration` did not exist locally. - -### Skills Used - -Local skills available under `.agents/skills/`: - -- `assertion-quality` -- `coverage-analysis` -- `detect-static-dependencies` -- `dotnet-aot-compat` -- `migrate-nullable-references` -- `msbuild-antipatterns` -- `msbuild-modernization` -- `run-tests` -- `test-anti-patterns` -- `test-gap-analysis` - -Skills used for this delivery: - -- `msbuild-modernization`: identify project style and migration-relevant MSBuild concerns. -- `msbuild-antipatterns`: classify current project-file risks without changing them. -- `run-tests`: select and document the correct baseline test approach for the current VSTest + xUnit 2 setup. - -### Repository Build Files - -Found: - -- `Dapper.FluentMap.sln` -- `NuGet.Config` -- `.appveyor.yml` -- `.travis.yml` -- Four SDK-style `.csproj` files. - -Not found: - -- `global.json` -- `Directory.Build.props` -- `Directory.Build.targets` -- `Directory.Packages.props` -- `.editorconfig` -- `.github/workflows/` -- `*.props`, `*.targets`, `*.ps1`, `*.sh`, `*.cake`, `*.cmd`, or `*.bat` build scripts beyond the files listed above. - -### Solution Projects - -| Project | Path | Current TFM | Desired TFM | Notes | -|---|---|---|---|---| -| Dapper.FluentMap | `src/Dapper.FluentMap/Dapper.FluentMap.csproj` | `netstandard2.0` | `netstandard2.0` | Published core library. Uses `` with a single TFM. | -| Dapper.FluentMap.Dommel | `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj` | `netstandard2.0` | `netstandard2.0` | Published Dommel integration. Uses `` with a single TFM. | -| Dapper.FluentMap.Tests | `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | `netcoreapp3.1` | `net10.0` | xUnit 2 / VSTest test project. Uses SQLite integration tests. | -| Dapper.FluentMap.Dommel.Tests | `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | `netcoreapp3.1` | `net10.0` | xUnit 2 / VSTest test project. Includes `coverlet.collector`. | - -### Direct Dependencies - -| Project | Direct packages | -|---|---| -| `src/Dapper.FluentMap` | `Dapper 2.0.35` | -| `src/Dapper.FluentMap.Dommel` | `Dapper 2.0.35`, `Dommel 2.0.0` | -| `test/Dapper.FluentMap.Tests` | `Microsoft.NET.Test.Sdk 16.7.1`, `Microsoft.Data.Sqlite 3.1.32`, `xunit 2.4.1`, `xunit.runner.visualstudio 2.4.3` | -| `test/Dapper.FluentMap.Dommel.Tests` | `Microsoft.NET.Test.Sdk 16.7.1`, `xunit 2.4.1`, `xunit.runner.visualstudio 2.4.3`, `coverlet.collector 1.3.0` | - -See `dependency-matrix.md` for latest stable versions, compatibility notes, vulnerabilities, and planned actions. - -### Tests - -Detected test platform: - -- VSTest through `Microsoft.NET.Test.Sdk`. -- xUnit 2 through `xunit` and `xunit.runner.visualstudio`. -- No Microsoft Testing Platform signal found in `global.json`, project files, or shared props. - -Detected test count by source attributes: - -- `test/Dapper.FluentMap.Tests`: 45 `[Fact]` tests. -- `test/Dapper.FluentMap.Dommel.Tests`: 7 `[Fact]` tests. -- Total: 52 `[Fact]` tests. - -Coverage: - -- `coverlet.collector 1.3.0` is referenced only by `test/Dapper.FluentMap.Dommel.Tests`. -- No `runsettings` or custom coverage configuration was found. - -### CI - -| File | Current behavior | Migration risk | -|---|---|---| -| `.appveyor.yml` | Visual Studio 2019 image, runs `dotnet test`. | Image may not contain .NET 10 SDK/runtime. Review in Delivery 04. | -| `.travis.yml` | `dotnet: 3.1`, runs `dotnet test`. | Explicitly obsolete for `net10.0`; review in Delivery 04. | - -No GitHub Actions workflows were found. - -### Environment Baseline - -Sanitized `dotnet --info` summary: - -- Active SDK: `10.0.302` -- MSBuild: `18.6.11` -- Host runtime: `10.0.10` -- OS: Windows x64 -- Installed SDKs: `8.0.423`, `10.0.110`, `10.0.204`, `10.0.302` -- Installed `Microsoft.NETCore.App` runtimes: `8.0.29`, `10.0.8`, `10.0.10` -- `global.json`: not found -- .NET Core 3.1 runtime: not installed - -### Baseline Commands - -Commands requested by this delivery: - -| Command | Result | Cause | Classification | -|---|---|---|---| -| `dotnet --info` | Succeeded | SDK available. | Environment info. | -| `dotnet --list-sdks` | Succeeded | SDK available. | Environment info. | -| `dotnet --list-runtimes` | Succeeded | SDK available. | Environment info. | -| `dotnet restore ./Dapper.FluentMap.sln` | Failed | Global NuGet cache metadata for `microsoft.netcore.targets/1.1.0` is corrupted: invalid JSON start byte in `.nupkg.metadata`. | Environmental; not a code failure. | -| `dotnet build ./Dapper.FluentMap.sln` | Failed | Build performs restore first and hit the same global NuGet cache corruption. | Environmental; not a code failure. | -| `dotnet test ./Dapper.FluentMap.sln` | Failed | Test command performs restore first and hit the same global NuGet cache corruption. | Environmental; not a code failure. | - -Additional diagnostic commands using an isolated local package cache: - -| Command | Result | Cause | Classification | -|---|---|---|---| -| `dotnet restore ./Dapper.FluentMap.sln --packages ./.nuget/packages` | Succeeded | Avoided corrupted global NuGet package cache. | Confirms restore is viable. | -| `dotnet build ./Dapper.FluentMap.sln --no-restore` | Succeeded | Used assets from isolated restore. | Code compiles in Debug: 0 warnings, 0 errors. | -| `dotnet test ./Dapper.FluentMap.sln --no-build` | Failed | Testhost requires `Microsoft.NETCore.App 3.1.0`, which is not installed. Installed runtimes start at 8.0 and 10.0. | Environmental/runtime baseline failure caused by current `netcoreapp3.1` test TFM. | - -### Package Diagnostics - -Commands: - -- `dotnet list ./Dapper.FluentMap.sln package --include-transitive --no-restore` -- `dotnet list ./Dapper.FluentMap.sln package --outdated --include-transitive --no-restore` -- `dotnet list ./Dapper.FluentMap.sln package --deprecated --no-restore` -- `dotnet list ./Dapper.FluentMap.sln package --vulnerable --include-transitive --no-restore` - -Findings: - -- Direct production packages have no reported vulnerabilities or deprecation in the current graph. -- Current `xunit 2.4.1` is reported as deprecated/legacy with `xunit.v3` as suggested alternative. -- xUnit 3 migration is intentionally deferred to Delivery 05. -- Test graphs contain vulnerable transitives through old test/runtime packages: - - `Newtonsoft.Json 9.0.1` - - `System.Net.Http 4.3.0` - - `System.Text.RegularExpressions 4.3.0` - - `SQLitePCLRaw.lib.e_sqlite3 2.1.2` in `Dapper.FluentMap.Tests` - -## Decision - -### Safe Update Order - -1. Delivery 02: migrate test projects from `netcoreapp3.1` to `net10.0` and update test-only packages required for a supported test runtime. -2. Delivery 03: update `src/` project dependencies while preserving `netstandard2.0`. -3. Delivery 04: run full validation, package inspection, and CI review. -4. Delivery 05: migrate from xUnit 2 to xUnit 3 as a separate compatibility and syntax change. - -### Packages Planned for Delivery 02 - -Update only test project packages: - -- `Microsoft.NET.Test.Sdk`: `16.7.1` -> latest stable identified `18.8.1` -- `Microsoft.Data.Sqlite`: `3.1.32` -> latest stable identified `10.0.10` -- `xunit`: `2.4.1` -> latest stable xUnit 2 identified `2.9.3` -- `xunit.runner.visualstudio`: `2.4.3` -> latest stable identified `3.1.5` -- `coverlet.collector`: `1.3.0` -> latest stable identified `10.0.1`, if coverage collector remains referenced - -Do not introduce `xunit.v3` in Delivery 02. - -### Packages Planned for Delivery 03 - -Update only direct `src/` dependencies after tests can run on `net10.0`: - -- `Dapper`: `2.0.35` -> latest stable identified `2.1.79` -- `Dommel`: `2.0.0` -> latest stable identified `3.5.3` - -The Dommel update is a major-version jump and must be validated against the existing Dommel resolver behavior. - -### Packages Blocked by `netstandard2.0` - -No direct production dependency planned for Delivery 03 is currently blocked by `netstandard2.0`: - -- `Dapper 2.1.79` declares `netstandard2.0` compatibility. -- `Dommel 3.5.3` declares `netstandard2.0` compatibility. - -Test-only packages that do not declare `netstandard2.0` are not blockers because they are not published dependencies of the `src/` projects. - -### xUnit Strategy - -Delivery 02 keeps xUnit 2: - -- Keep test source syntax unchanged. -- Update `xunit` only to the latest stable xUnit 2 line. -- Use `xunit.runner.visualstudio` that can run xUnit 2 tests on modern VSTest. - -Delivery 05 handles xUnit 3: - -- Introduce `xunit.v3` packages only there. -- Re-check runner/platform syntax there. -- Treat xUnit 3 as an independent migration because package IDs, runner behavior, analyzers, and discovery can change. - -### `netstandard2.0` Consumption Validation - -Use the `net10.0` test projects as consumers of the `netstandard2.0` `src/` projects: - -1. Restore the solution. -2. Build `src/Dapper.FluentMap` and `src/Dapper.FluentMap.Dommel` in Release. -3. Run both test projects on `net10.0`. -4. Keep Dapper integration tests active so materialization/type-map behavior is exercised by a real `net10.0` testhost. -5. In Delivery 04, run `dotnet pack` and inspect package dependency groups to confirm published outputs remain `netstandard2.0`. - -### Known Risks - -- Current default restore is blocked by a corrupted global NuGet cache entry. Later deliveries may need an isolated package cache or a user-performed cache cleanup. -- Current tests cannot run on this machine until the test TFM moves off `netcoreapp3.1` or the obsolete runtime is installed. Do not install .NET Core 3.1 automatically. -- `Dommel 2.0.0` -> `3.5.3` is a major update; validate integration behavior carefully. -- `coverlet.collector 10.0.1` requires modern SDK/test SDK support; update it together with `Microsoft.NET.Test.Sdk`. -- CI files are legacy and likely incompatible with `net10.0`; review after local migration succeeds. -- Project files use `` for a single target. This is an existing MSBuild style issue, but changing it should be done only in the delivery that edits project files. - -## Delivery - -Created SDD handoff files only: - -- `docs/sdd/net10-migration/README.md` -- `docs/sdd/net10-migration/status.md` -- `docs/sdd/net10-migration/decisions.md` -- `docs/sdd/net10-migration/dependency-matrix.md` -- `docs/sdd/net10-migration/01-inventory-baseline.md` - -No `.csproj`, C# source, solution, dependency, CI, or packaging files were changed. - -No `.gitignore` change was needed because generated restore/build/test outputs are already ignored: - -- `.nuget/` -- `artifacts/` -- `bin/` -- `obj/` -- `TestResults/` - -## Validation - -Validation checklist: - -- All solution projects inventoried: yes. -- All direct dependencies registered: yes. -- Latest stable versions identified: yes, using NuGet.org source and `dotnet list package --outdated`. -- Baseline commands documented: yes. -- Shared branch documented: yes. -- Functional files unchanged: yes; documentation-only delivery. -- Absolute local paths omitted from documentation: yes. -- Sensitive data documented: no. -- Handoff folder sufficient for next chats: yes. - -Commands to run before committing: - -```bash -git diff -git status -``` diff --git a/docs/sdd/net10-migration/02-test-projects-net10.md b/docs/sdd/net10-migration/02-test-projects-net10.md deleted file mode 100644 index 77af812..0000000 --- a/docs/sdd/net10-migration/02-test-projects-net10.md +++ /dev/null @@ -1,182 +0,0 @@ -# 02 - Test Projects on net10.0 - -## Specification - -Migrate all projects under `test/` from `netcoreapp3.1` to `net10.0`, preserving the published `src/` projects on `netstandard2.0`. - -Expected consumption shape: - -```text -test net10.0 - -> ProjectReference -src netstandard2.0 -``` - -Scope limits for this delivery: - -- Update only test project TFMs and test-only dependencies needed for `net10.0`. -- Keep xUnit 2; do not introduce `xunit.v3`. -- Do not update `Dapper`, `Dommel`, public APIs, production behavior, package metadata, or CI. -- Do not skip, remove, or weaken tests. - -## Discovery - -### Recovered Context - -- `AGENTS.md` was read before changes. -- Required migration handoff files were read: - - `docs/sdd/net10-migration/README.md` - - `docs/sdd/net10-migration/status.md` - - `docs/sdd/net10-migration/decisions.md` - - `docs/sdd/net10-migration/dependency-matrix.md` - - `docs/sdd/net10-migration/01-inventory-baseline.md` -- Shared branch recorded in `README.md`: `chore/net10-migration`. -- Current branch: `chore/net10-migration`. -- Delivery 01 is concluded in `status.md` and the latest commit is `docs: document .NET 10 migration baseline`. - -### Skills Used - -- `msbuild-modernization`: selected for TargetFramework/PackageReference migration guidance. -- `run-tests`: selected to detect test runner/platform and use the correct `dotnet test` commands. - -### Projects Under `test/` - -| Project | Current target element | Current TFM | Project reference | -|---|---|---|---| -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | `TargetFrameworks` | `netcoreapp3.1` | `src/Dapper.FluentMap/Dapper.FluentMap.csproj` | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | `TargetFrameworks` | `netcoreapp3.1` | `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj` | - -### Shared Build Configuration - -No shared build/test configuration files were found: - -- no `global.json` -- no `Directory.Build.props` -- no `Directory.Build.targets` -- no `Directory.Packages.props` -- no `packages.lock.json` -- no `*.runsettings` -- no `.editorconfig` - -Test runner detection: - -- VSTest through `Microsoft.NET.Test.Sdk`. -- xUnit 2 through `xunit` and `xunit.runner.visualstudio`. -- No Microsoft Testing Platform signal was found. - -### Existing Test Dependencies - -| Project | Package | Current version | -|---|---|---:| -| `Dapper.FluentMap.Tests` | `Microsoft.NET.Test.Sdk` | `16.7.1` | -| `Dapper.FluentMap.Tests` | `Microsoft.Data.Sqlite` | `3.1.32` | -| `Dapper.FluentMap.Tests` | `xunit` | `2.4.1` | -| `Dapper.FluentMap.Tests` | `xunit.runner.visualstudio` | `2.4.3` | -| `Dapper.FluentMap.Dommel.Tests` | `Microsoft.NET.Test.Sdk` | `16.7.1` | -| `Dapper.FluentMap.Dommel.Tests` | `xunit` | `2.4.1` | -| `Dapper.FluentMap.Dommel.Tests` | `xunit.runner.visualstudio` | `2.4.3` | -| `Dapper.FluentMap.Dommel.Tests` | `coverlet.collector` | `1.3.0` | - -`dotnet list package --outdated --include-transitive --no-restore` and NuGet.org package pages confirmed the Delivery 01 matrix still matches the latest stable versions on 2026-07-25. - -### Test Code Compatibility Scan - -Searches under `test/` found: - -- `[assembly: CollectionBehavior(DisableTestParallelization = true)]` in both test assemblies. -- Shared global state use through `FluentMapper`, `FluentMapper.Reset`, `FluentMapper.EntityMaps`, `FluentMapper.TypeConventions`, and Dapper type-map integration. -- xUnit 2 `[Fact]` and `[Trait]` usage. -- No `Thread.Sleep`, `Task.Delay`, remoting, `BinaryFormatter`, broad warning suppression, `async void`, blocking async waits, or obvious .NET 10 removed API usage in tests. -- Existing `Assert.Throws` remains unchanged because it is unrelated to this runtime migration. - -## Decision - -### Final Test Targets - -| Project | Final target element | Final TFM | -|---|---|---| -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | `TargetFramework` | `net10.0` | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | `TargetFramework` | `net10.0` | - -Use singular `TargetFramework` because each test project has one target. Do not normalize the `src/` projects in this delivery even though they currently use `TargetFrameworks` with one target. - -### Test Package Updates - -| Package | Old version | New version | Justification | -|---|---:|---:|---| -| `Microsoft.NET.Test.Sdk` | `16.7.1` | `18.8.1` | Required to run tests reliably on modern SDK/VSTest and removes old vulnerable test-platform transitives from the test graph. | -| `Microsoft.Data.Sqlite` | `3.1.32` | `10.0.10` | Test-only SQLite provider used by integration tests; aligns native/runtime assets with `net10.0` while leaving production Dapper dependencies untouched. | -| `xunit` | `2.4.1` | `2.9.3` | Latest stable xUnit 2 line; preserves xUnit 2 API and defers `xunit.v3` to Delivery 05. | -| `xunit.runner.visualstudio` | `2.4.3` | `3.1.5` | Modern VSTest adapter that supports .NET 8+ and can run xUnit 2 tests. Keep `PrivateAssets="all"`. | -| `coverlet.collector` | `1.3.0` | `10.0.1` | Coverage collector version compatible with modern SDK/test SDK. Keep `PrivateAssets="all"`. | - -### Dependencies Left Temporarily Old - -- `Dapper 2.0.35` in `src/` remains for Delivery 03. -- `Dommel 2.0.0` in `src/Dapper.FluentMap.Dommel` remains for Delivery 03. -- Production `src/` targets remain `netstandard2.0`. -- xUnit 3 remains deferred to Delivery 05. - -### Behavior and Risk Controls - -- Preserve test source behavior; no test code changes are planned unless build/test exposes a direct `net10.0` incompatibility. -- Preserve disabled parallel execution because the suites share global FluentMapper/Dapper state. -- Keep VSTest runner model because no MTP signal exists. -- Do not alter public API, mapping behavior, package metadata, or CI in this delivery. - -## Delivery - -- Migrated `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` from `TargetFrameworks netcoreapp3.1` to `TargetFramework net10.0`. -- Migrated `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` from `TargetFrameworks netcoreapp3.1` to `TargetFramework net10.0`. -- Updated test-only packages: - - `Microsoft.NET.Test.Sdk` `16.7.1` -> `18.8.1` - - `Microsoft.Data.Sqlite` `3.1.32` -> `10.0.10` - - `xunit` `2.4.1` -> `2.9.3` - - `xunit.runner.visualstudio` `2.4.3` -> `3.1.5` - - `coverlet.collector` `1.3.0` -> `10.0.1` -- No C# test code changes were required. -- No tests were skipped, removed, or weakened. -- No `src/` project files were changed; both published projects remain `netstandard2.0`. -- No production dependencies were updated. -- No CI, package metadata, or public API was changed. - -## Validation - -Environment: - -- Active SDK: `10.0.302` -- Test runner: VSTest -- Test framework: xUnit 2 - -Commands executed: - -| Command | Result | -|---|---| -| `dotnet restore` | Passed with NU1903 warning for transitive `SQLitePCLRaw.lib.e_sqlite3 2.1.11` in `Dapper.FluentMap.Tests`. | -| `dotnet build` | Passed; `src` outputs built under `netstandard2.0`, test outputs under `net10.0`. | -| `dotnet test` | Passed; `Dapper.FluentMap.Tests`: 45 passed, 0 failed, 0 skipped; `Dapper.FluentMap.Dommel.Tests`: 7 passed, 0 failed, 0 skipped. | -| `dotnet test test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | Passed; 45 passed, 0 failed, 0 skipped. | -| `dotnet test test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | Passed; 7 passed, 0 failed, 0 skipped. | -| `dotnet build --configuration Release` | Passed; `src` outputs built under `netstandard2.0`, test outputs under `net10.0`. | -| `dotnet test --configuration Release` | Passed; `Dapper.FluentMap.Tests`: 45 passed, 0 failed, 0 skipped; `Dapper.FluentMap.Dommel.Tests`: 7 passed, 0 failed, 0 skipped. | -| `dotnet list .\Dapper.FluentMap.sln package --include-transitive --no-restore` | Passed; confirmed test projects resolve as `net10.0` and `src` projects as `netstandard2.0`. | -| `dotnet list .\Dapper.FluentMap.sln package --outdated --include-transitive --no-restore` | Passed; direct test packages are current, while production `Dapper`/`Dommel`, xUnit analyzer transitives, and SQLitePCLRaw transitives remain visible. | -| `dotnet list .\Dapper.FluentMap.sln package --vulnerable --include-transitive --no-restore` | Passed; only `Dapper.FluentMap.Tests` reports transitive `SQLitePCLRaw.lib.e_sqlite3 2.1.11` high severity. | -| `dotnet list .\Dapper.FluentMap.sln package --deprecated --no-restore` | Passed; `xunit 2.9.3` remains marked legacy with `xunit.v3` alternative, intentionally deferred to Delivery 05. | - -Explicit confirmations: - -- Test projects compile and execute for `net10.0`. -- `src/Dapper.FluentMap` remains `netstandard2.0`. -- `src/Dapper.FluentMap.Dommel` remains `netstandard2.0`. -- `net10.0` test projects consume `netstandard2.0` production projects through `ProjectReference`. -- No test was ignored to hide a migration issue. -- No unrelated functional behavior was changed. -- Repeated Debug/Release test runs were deterministic in result counts. - -Residual risks: - -- `Microsoft.Data.Sqlite 10.0.10` still resolves vulnerable transitive `SQLitePCLRaw.lib.e_sqlite3 2.1.11`. This did not block restore/build/test, but should be reviewed in Delivery 04 or in a dedicated dependency-hardening task before release. -- `Dapper 2.0.35` and `Dommel 2.0.0` remain intentionally pending for Delivery 03. -- xUnit 3 remains intentionally pending for Delivery 05. -- `dotnet pack` was not run because this delivery did not alter published package projects or package metadata. diff --git a/docs/sdd/net10-migration/03-src-dependencies.md b/docs/sdd/net10-migration/03-src-dependencies.md deleted file mode 100644 index 005f1f7..0000000 --- a/docs/sdd/net10-migration/03-src-dependencies.md +++ /dev/null @@ -1,206 +0,0 @@ -# 03 - Source Project Dependencies - -## Specification - -Update direct production dependencies in `src/` to the newest stable versions that preserve: - -- published source projects on `netstandard2.0`; -- `net10.0` test projects consuming the libraries through `ProjectReference`; -- public API and behavior unless a dependency incompatibility requires a minimal adjustment. - -Do not migrate the source projects to multi-targeting, do not change package metadata, and do not update test-only packages in this delivery. - -## Discovery - -### Recovered Context - -- `AGENTS.md` was read before changes. -- Required migration handoff files were read: - - `docs/sdd/net10-migration/README.md` - - `docs/sdd/net10-migration/status.md` - - `docs/sdd/net10-migration/decisions.md` - - `docs/sdd/net10-migration/dependency-matrix.md` - - `docs/sdd/net10-migration/01-inventory-baseline.md` - - `docs/sdd/net10-migration/02-test-projects-net10.md` -- Shared branch recorded in `README.md`: `chore/net10-migration`. -- Current branch: `chore/net10-migration`. -- Delivery 01 is concluded in `status.md`. -- Delivery 02 is concluded in `status.md`. - -### Project Targets - -| Project | Target element | Current TFM | Required final TFM | -|---|---|---|---| -| `src/Dapper.FluentMap/Dapper.FluentMap.csproj` | `TargetFrameworks` | `netstandard2.0` | `netstandard2.0` | -| `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj` | `TargetFrameworks` | `netstandard2.0` | `netstandard2.0` | -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | `TargetFramework` | `net10.0` | `net10.0` | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | `TargetFramework` | `net10.0` | `net10.0` | - -The `src/` projects still use `TargetFrameworks` with a single target. This delivery preserves that shape to avoid unrelated published-project churn. - -### Skills Used - -- `msbuild-modernization`: selected for TFM and PackageReference guardrails. -- `msbuild-antipatterns`: selected for project-file dependency review. -- `run-tests`: selected to run the correct VSTest/xUnit 2 validation commands. - -### Package Metadata Sources - -Version discovery and compatibility were checked using: - -- `dotnet list .\Dapper.FluentMap.sln package --outdated --include-transitive` -- `dotnet list .\Dapper.FluentMap.sln package --include-transitive --no-restore` -- `dotnet list .\Dapper.FluentMap.sln package --vulnerable --include-transitive --no-restore` -- NuGet flat container metadata: - - `https://api.nuget.org/v3-flatcontainer/dapper/index.json` - - `https://api.nuget.org/v3-flatcontainer/dapper/2.1.79/dapper.nuspec` - - `https://api.nuget.org/v3-flatcontainer/dommel/index.json` - - `https://api.nuget.org/v3-flatcontainer/dommel/3.5.3/dommel.nuspec` -- NuGet Gallery package pages: - - `https://www.nuget.org/packages/Dapper` - - `https://www.nuget.org/packages/Dommel` -- Official source/release pages where available: - - `https://github.com/DapperLib/Dapper/releases` - - `https://github.com/henkmollema/Dommel/releases` - -### Direct Production Dependencies Before Update - -| Project | Package | Current version | -|---|---|---:| -| `src/Dapper.FluentMap` | `Dapper` | `2.0.35` | -| `src/Dapper.FluentMap.Dommel` | `Dapper` | `2.0.35` | -| `src/Dapper.FluentMap.Dommel` | `Dommel` | `2.0.0` | - -### Relevant API Usage - -Core Dapper integration uses public Dapper APIs: - -- `SqlMapper.ITypeMap` -- `SqlMapper.IMemberMap` -- `SqlMapper.SetTypeMap` -- `CustomPropertyTypeMap` -- `DefaultTypeMap` - -Dommel integration uses public Dommel APIs: - -- `DommelMapper.SetColumnNameResolver` -- `DommelMapper.SetKeyPropertyResolver` -- `DommelMapper.SetTableNameResolver` -- `DommelMapper.SetPropertyResolver` -- `IColumnNameResolver` -- `IKeyPropertyResolver` -- `ITableNameResolver` -- `IPropertyResolver` -- `Default*Resolver` -- `ColumnPropertyInfo` - -### Relevant Transitives Before Update - -| Area | Package | Resolved version | Finding | -|---|---|---:|---| -| `src` via Dapper/netstandard graph | `Microsoft.NETCore.Platforms` | `1.1.0` | Old transitive from the netstandard restore graph; not a direct dependency to force. | -| Dommel integration | `System.ComponentModel.Annotations` | `4.7.0` | Transitive dependency of `Dommel 2.0.0`; latest Dommel updates this to `5.0.0` for `netstandard2.0`. | -| Core tests | `SQLitePCLRaw.lib.e_sqlite3` | `2.1.11` | Known NU1903 high severity warning remains from Delivery 02; test-only transitive and outside this source-dependency delivery. | - -## Decision - -### Dependency Selection Table - -| Project | Package | Previous version | Chosen version | Latest stable available | Reason for choice | `netstandard2.0` compatibility | Breaking changes evaluated | Code correction expected | -|---|---|---:|---:|---:|---|---|---|---| -| `src/Dapper.FluentMap` | `Dapper` | `2.0.35` | `2.1.79` | `2.1.79` | Latest stable from NuGet; package includes `netstandard2.0` assets and keeps the public type-map APIs used by FluentMap. | Compatible. NuGet metadata declares `.NETStandard2.0` with dependencies on `Microsoft.Bcl.AsyncInterfaces`, `System.Reflection.Emit.Lightweight`, and `System.Threading.Tasks.Extensions`. | Dapper release notes from the 2.1 line include TFM changes, async API normalization, DateOnly/TimeOnly support disablement after unlisted releases, type-handler fixes, and dependency updates. FluentMap does not use DateOnly/TimeOnly support or obsolete internal type-handler APIs. | None expected; compile and Dapper integration tests must confirm. | -| `src/Dapper.FluentMap.Dommel` | `Dapper` | `2.0.35` | `2.1.79` | `2.1.79` | Keep the integration aligned with the core Dapper version and avoid a lower direct version than Dommel transitively requires. | Compatible, same as core. | Same Dapper review as the core project. | None expected; compile and Dommel tests must confirm. | -| `src/Dapper.FluentMap.Dommel` | `Dommel` | `2.0.0` | `3.5.3` | `3.5.3` | Latest stable from NuGet; package includes `netstandard2.0` assets and preserves Dommel's resolver extension model according to package metadata and source surface to be validated by compile/tests. | Compatible. NuGet metadata declares `.NETStandard2.0` with dependencies on `Dapper 2.1.72`, `Microsoft.Bcl.HashCode 6.0.0`, and `System.ComponentModel.Annotations 5.0.0`. | Major-version update. Release page has tags through the 3.5 line but no detailed migration notes for 3.5.3 were found; resolver API compatibility will be verified by compilation and existing Dommel resolver tests. | Possible minimal resolver signature adjustment if the public Dommel interfaces changed. | - -### Update Categories - -Safe updates: - -- `Dapper 2.0.35` -> `2.1.79` in both source projects, pending build/test confirmation. - -Updates that require focused validation: - -- `Dommel 2.0.0` -> `3.5.3` because it is a major-version jump and the integration implements Dommel resolver interfaces. - -Blocked updates: - -- None for direct production dependencies. No selected latest stable direct production package is blocked by `netstandard2.0`. - -Deferred updates: - -- `xunit` / `xunit.v3`: deferred to Delivery 05. -- `SQLitePCLRaw.*` test transitives: defer to Delivery 04 or a dedicated dependency-hardening task unless source dependency updates naturally change the graph. -- `xunit.analyzers`: transitive of `xunit`; do not force as a direct dependency in this delivery. -- `Microsoft.NETCore.Platforms`: transitive netstandard graph package; do not force as a direct dependency. - -## Delivery - -- Updated `src/Dapper.FluentMap/Dapper.FluentMap.csproj`: - - `Dapper` `2.0.35` -> `2.1.79` -- Updated `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj`: - - `Dapper` `2.0.35` -> `2.1.79` - - `Dommel` `2.0.0` -> `3.5.3` -- Preserved `TargetFrameworks netstandard2.0` in both `src/` projects. -- Preserved `TargetFramework net10.0` in both test projects. -- No C# code changes were required. -- No public API, package metadata, CI, xUnit packages, or test code was changed. -- No tests were skipped, removed, or weakened. - -### Direct Production Dependencies After Update - -| Project | Package | Final version | Status | -|---|---|---:|---| -| `src/Dapper.FluentMap` | `Dapper` | `2.1.79` | Updated to latest stable. | -| `src/Dapper.FluentMap.Dommel` | `Dapper` | `2.1.79` | Updated to latest stable and aligned with core. | -| `src/Dapper.FluentMap.Dommel` | `Dommel` | `3.5.3` | Updated to latest stable. | - -### Post-Update Dependency Findings - -| Area | Package | Resolved version | Latest stable identified | Handling | -|---|---|---:|---:|---| -| `src` via Dapper `netstandard2.0` graph | `Microsoft.Bcl.AsyncInterfaces` | `10.0.8` | `10.0.10` | Do not force as direct dependency; Dapper declares `>= 10.0.8` and restore chose the dependency floor. | -| `src` via `NETStandard.Library` graph | `Microsoft.NETCore.Platforms` | `1.1.0` | `7.0.4` | Do not force as direct dependency. Existing netstandard graph behavior. | -| Test transitives | `SQLitePCLRaw.*` | `2.1.11` | `3.0.4` / `3.53.3` | Test-only SQLite transitives remain deferred to Delivery 04 or dependency hardening. | -| Test transitives | `xunit.analyzers` | `1.18.0` | `1.27.0` | Transitive of xUnit 2; defer to Delivery 05. | - -## Validation - -Environment: - -- Active SDK: `10.0.302` -- Test runner: VSTest -- Test framework: xUnit 2 - -Commands executed: - -| Command | Result | -|---|---| -| `dotnet restore` | Passed with the existing NU1903 warning for transitive `SQLitePCLRaw.lib.e_sqlite3 2.1.11` in `Dapper.FluentMap.Tests`. | -| `dotnet build` | Passed; `src` outputs built under `netstandard2.0`, test outputs under `net10.0`. | -| `dotnet test` | Passed; `Dapper.FluentMap.Tests`: 45 passed, 0 failed, 0 skipped; `Dapper.FluentMap.Dommel.Tests`: 7 passed, 0 failed, 0 skipped. | -| `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj` | Passed; 45 passed, 0 failed, 0 skipped. | -| `dotnet test .\test\Dapper.FluentMap.Dommel.Tests\Dapper.FluentMap.Dommel.Tests.csproj` | Passed; 7 passed, 0 failed, 0 skipped. | -| `dotnet build --configuration Release` | Passed; `src` outputs built under `netstandard2.0`, test outputs under `net10.0`. | -| `dotnet test --configuration Release` | Passed; `Dapper.FluentMap.Tests`: 45 passed, 0 failed, 0 skipped; `Dapper.FluentMap.Dommel.Tests`: 7 passed, 0 failed, 0 skipped. | -| `dotnet list .\Dapper.FluentMap.sln package --include-transitive --no-restore` | Passed; direct production dependencies resolve to `Dapper 2.1.79` and `Dommel 3.5.3`. | -| `dotnet list .\Dapper.FluentMap.sln package --outdated --include-transitive --no-restore` | Passed; no outdated direct production package remains. Only deferred transitives were reported. | -| `dotnet list .\Dapper.FluentMap.sln package --vulnerable --include-transitive --no-restore` | Passed; no vulnerable packages in `src/`; existing vulnerable test transitive `SQLitePCLRaw.lib.e_sqlite3 2.1.11` remains in `Dapper.FluentMap.Tests`. | -| `dotnet list .\Dapper.FluentMap.sln package --deprecated --no-restore` | Passed; no deprecated packages in `src/`; `xunit 2.9.3` remains legacy in test projects and is deferred to Delivery 05. | - -Explicit confirmations: - -- Shared branch is `chore/net10-migration`. -- Deliveries 01 and 02 are concluded in `status.md`. -- Test projects remain `net10.0`. -- Source projects remain `netstandard2.0`. -- All direct production package references restored. -- No package downgrade or conflict was reported by restore/build. -- Dapper integration behavior is covered by the existing core integration tests on `net10.0`. -- Dommel resolver behavior is covered by the existing Dommel tests on `net10.0`. -- No unnecessary public breaking change was introduced. - -Residual risks and Delivery 04 handoff: - -- `dotnet pack` and package content/dependency-group inspection remain for Delivery 04. -- `SQLitePCLRaw.lib.e_sqlite3 2.1.11` still reports NU1903 in the core test project and should be reviewed in Delivery 04 or separately. -- `Dommel 3.5.3` had no detailed 3.5.3 migration notes found on the release page; compile/tests validate the resolver surface used here, but Delivery 04 should keep package inspection focused. diff --git a/docs/sdd/net10-migration/04-validation-pack-ci.md b/docs/sdd/net10-migration/04-validation-pack-ci.md deleted file mode 100644 index a3d6578..0000000 --- a/docs/sdd/net10-migration/04-validation-pack-ci.md +++ /dev/null @@ -1,329 +0,0 @@ -# 04 - Validation, Pack and CI - -## Specification - -This delivery consolidates the .NET 10 migration completed by Deliveries 01, 02 and 03. - -Expected final matrix: - -```text -src/ -|-- Dapper.FluentMap -> netstandard2.0 -`-- Dapper.FluentMap.Dommel -> netstandard2.0 - -test/ -|-- Dapper.FluentMap.Tests -> net10.0 -`-- Dapper.FluentMap.Dommel.Tests -> net10.0 -``` - -The validation must prove: - -- `src/` projects still compile for `netstandard2.0`. -- `test/` projects compile and execute for `net10.0`. -- `net10.0` test projects consume the `netstandard2.0` libraries through `ProjectReference`. -- dependencies restore without downgrade or target compatibility errors. -- Debug and Release builds work. -- NuGet packages can be generated and inspected. -- package contents include expected `lib/netstandard2.0` assemblies and exclude test/local artifacts. -- CI installs or selects a .NET 10 compatible SDK. -- CI does not publish NuGet packages. - -Out of scope: - -- xUnit 3 migration. -- functional library changes. -- public API changes. -- moving `src/` projects to `net10.0`. -- source multi-targeting. -- publishing packages. -- pushing the branch or opening a pull request. - -## Discovery - -### Recovered Context - -- `AGENTS.md` was read before changes. -- Local skills under `.agents/skills/` were checked. -- Skills used: - - `run-tests` for VSTest/xUnit 2 command selection. - - `msbuild-modernization` for TargetFramework and SDK guardrails. - - `msbuild-antipatterns` for project-file review. -- Required handoff files were read: - - `docs/sdd/net10-migration/README.md` - - `docs/sdd/net10-migration/status.md` - - `docs/sdd/net10-migration/decisions.md` - - `docs/sdd/net10-migration/dependency-matrix.md` - - `docs/sdd/net10-migration/01-inventory-baseline.md` - - `docs/sdd/net10-migration/02-test-projects-net10.md` - - `docs/sdd/net10-migration/03-src-dependencies.md` -- Shared branch recorded in `README.md`: `chore/net10-migration`. -- Current branch: `chore/net10-migration`. -- Deliveries 01, 02 and 03 are concluded in `status.md`. - -### Local State - -- Active SDK: `10.0.302`. -- Host runtime: `10.0.10`. -- `global.json`: not found. -- `Directory.Build.props`: not found. -- `Directory.Build.targets`: not found. -- `Directory.Packages.props`: not found. -- `.editorconfig`: not found. -- Build scripts (`*.ps1`, `*.sh`, `*.cmd`, `*.bat`, `*.cake`): not found. -- `.gitignore` already excludes local restore/build/package outputs: - - `.nuget/` - - `bin/` - - `obj/` - - `TestResults/` - - `artifacts/` - -### Project Targets - -| Project | Target element | Effective target | -|---|---|---| -| `src/Dapper.FluentMap/Dapper.FluentMap.csproj` | `TargetFrameworks` | `netstandard2.0` | -| `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj` | `TargetFrameworks` | `netstandard2.0` | -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | `TargetFramework` | `net10.0` | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | `TargetFramework` | `net10.0` | - -The `src/` projects still use `TargetFrameworks` with a single TFM. This is existing shape from earlier deliveries and is preserved to avoid published-project churn. - -### Package and Pack Configuration - -- Published projects: - - `src/Dapper.FluentMap` - - `src/Dapper.FluentMap.Dommel` -- Test projects have `false`. -- Source package metadata is unchanged from earlier deliveries: - - `VersionPrefix` is `2.0.0`. - - authors and copyright remain Henk Mollema. - - `PackageProjectUrl` points to the original repository. - - `PackageLicenseUrl` is present; no metadata modernization is introduced here. -- No `.nuspec`, SourceLink, symbol package, package README, or repository metadata file was found. -- `NuGet.Config` uses only `https://api.nuget.org/v3/index.json` after clearing inherited sources. - -### Test Consumption Evidence - -- `test/Dapper.FluentMap.Tests` references `src/Dapper.FluentMap`. -- `test/Dapper.FluentMap.Dommel.Tests` references `src/Dapper.FluentMap.Dommel`. -- Existing tests exercise real library behavior through: - - `FluentMapper.Initialize` - - Dapper `SqlMapper` type-map resolution. - - SQLite-backed Dapper integration tests. - - Dommel resolver integration tests. -- No additional compatibility project is required because the `net10.0` test projects already consume the `netstandard2.0` source projects. - -### CI State - -Found CI files: - -| File | Current state | Risk | -|---|---|---| -| `.appveyor.yml` | Visual Studio 2019 image, runs only `dotnet test`. | Does not explicitly install/select .NET 10 and does not validate pack. | -| `.travis.yml` | `dotnet: 3.1`, `dist: xenial`, runs only `dotnet test`. | Incompatible with `net10.0` test projects and obsolete distro/runtime. | - -No `.github/workflows/` directory exists. - -No CI file currently runs `dotnet nuget push`, publishes packages, uses NuGet tokens, uses `continue-on-error`, references `poc-arquitetura`, or creates a fake test framework matrix. - -### Dependency State - -- Direct production dependencies are already updated by Delivery 03: - - `Dapper 2.1.79` - - `Dommel 3.5.3` -- Test dependencies are already updated by Delivery 02: - - `Microsoft.NET.Test.Sdk 18.8.1` - - `Microsoft.Data.Sqlite 10.0.10` - - `xunit 2.9.3` - - `xunit.runner.visualstudio 3.1.5` - - `coverlet.collector 10.0.1` -- Known deferred items: - - xUnit 3 migration is Delivery 05. - - `xunit.analyzers` remains transitive to xUnit 2 and is deferred with Delivery 05. - - `SQLitePCLRaw.lib.e_sqlite3 2.1.11` remains a vulnerable test transitive reported by NuGet; handle as separate dependency-hardening work unless Delivery 05 changes it naturally. - -## Decision - -### Commands - -Official local and CI commands for this delivery: - -```bash -dotnet restore ./Dapper.FluentMap.sln -dotnet build ./Dapper.FluentMap.sln --no-restore -dotnet test ./Dapper.FluentMap.sln --no-build -dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore -dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build -dotnet test ./test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj --configuration Release -dotnet test ./test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj --configuration Release -dotnet pack ./Dapper.FluentMap.sln --configuration Release --no-build --output ./artifacts/packages -``` - -`dotnet pack --no-build` is retained because Release build runs first and test projects are marked non-packable. - -### SDK and `global.json` - -Do not create `global.json` in this delivery. - -Reasoning: - -- The repo currently has no `global.json`. -- Local validation already uses a stable .NET 10 SDK (`10.0.302`). -- CI can explicitly install the stable .NET 10 channel through setup steps. -- Pinning an exact SDK file now would add maintenance and could be more brittle than the current small-library setup. - -CI should use the .NET 10 SDK channel with GA quality where supported. - -### CI Plan - -Add GitHub Actions CI because no GitHub workflow exists and the project is hosted as a GitHub repository. - -Update legacy CI files so they no longer keep obsolete SDK assumptions: - -- `.appveyor.yml`: use a newer Windows image and install .NET 10 explicitly before restore/build/test/pack. -- `.travis.yml`: move from `dotnet: 3.1`/`xenial` to a .NET 10 compatible configuration and run the same restore/build/test/pack sequence. - -GitHub Actions choices: - -- `ubuntu-latest`. -- `actions/checkout` current major from the official action README. -- `actions/setup-dotnet` current major from the official action README. -- `dotnet-version: 10.0.x`. -- `dotnet-quality: ga`. -- no NuGet cache because there is no lock file and setup-dotnet cache requires lock files. -- upload `artifacts/packages/*.nupkg` as a CI artifact. -- no matrix, because tests target only `net10.0`. -- no package publishing or NuGet token configuration. - -### Validation Criteria - -Migration is valid when: - -- required local restore/build/test/pack commands pass, allowing documented NuGet vulnerability warnings. -- both test projects pass directly in Release. -- package inspection confirms only expected package contents and dependency groups. -- CI YAML parses as YAML and contains no publish/token/obsolete-framework commands. -- final git diff contains only CI/config/docs changes required by this delivery. - -## Delivery - -- Added `.github/workflows/ci.yml`: - - installs .NET SDK `10.0.x` with GA quality. - - runs `dotnet --info`. - - restores `Dapper.FluentMap.sln`. - - builds Release with `--no-restore`. - - tests Release with `--no-build`. - - packs Release with `--no-build`. - - uploads generated `.nupkg` files as workflow artifacts. - - uses no NuGet publish command, token, secret or package source mutation. -- Updated `.appveyor.yml`: - - moved from `Visual Studio 2019` to `Visual Studio 2022`. - - installs .NET SDK 10 GA through `dotnet-install.ps1`. - - runs restore, Release build, Release tests and Release pack. - - stores generated `.nupkg` files as AppVeyor artifacts. - - keeps `test: off` because tests are executed explicitly in the build script. -- Updated `.travis.yml`: - - moved from `dotnet: 3.1` and `dist: xenial` to `dotnet: 10.0` and `dist: jammy`. - - runs restore, Release build, Release tests and Release pack. -- No `global.json` was created. -- No project file, C# source file, public API, package metadata, dependency version, or Dommel behavior was changed. -- No package was published. - -## Validation - -### Commands Executed - -| Command | Result | -|---|---| -| `dotnet --info` | Passed. Active SDK `10.0.302`; host runtime `10.0.10`; no `global.json`. | -| `dotnet restore` | Passed with existing NU1903 warning for transitive `SQLitePCLRaw.lib.e_sqlite3 2.1.11` in `Dapper.FluentMap.Tests`. | -| `dotnet build --no-restore` | Passed. `src` outputs under `Debug/netstandard2.0`; tests under `Debug/net10.0`. | -| `dotnet test --no-build` | Passed. `Dapper.FluentMap.Tests`: 45 passed; `Dapper.FluentMap.Dommel.Tests`: 7 passed. | -| `dotnet build --configuration Release --no-restore` | Passed. `src` outputs under `Release/netstandard2.0`; tests under `Release/net10.0`. | -| `dotnet test --configuration Release --no-build` | Passed. `Dapper.FluentMap.Tests`: 45 passed; `Dapper.FluentMap.Dommel.Tests`: 7 passed. | -| `dotnet test test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj --configuration Release` | Passed. Restored/built the core library as `netstandard2.0`, ran 45 `net10.0` tests. | -| `dotnet test test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj --configuration Release` | Passed. Restored/built core and Dommel libraries as `netstandard2.0`, ran 7 `net10.0` tests. | -| `dotnet pack .\Dapper.FluentMap.sln --configuration Release --no-build --output .\artifacts\packages` | Passed. Generated both expected `.nupkg` files. Warnings: NU5125 for deprecated `licenseUrl`; package README recommendation. | -| `dotnet list .\Dapper.FluentMap.sln package --include-transitive --no-restore` | Passed. Confirmed final dependency graph and TFMs. | -| `dotnet list .\Dapper.FluentMap.sln package --outdated --include-transitive --no-restore` | Passed. No outdated direct packages; deferred transitives remain. | -| `dotnet list .\Dapper.FluentMap.sln package --vulnerable --include-transitive --no-restore` | Passed. Only known test transitive `SQLitePCLRaw.lib.e_sqlite3 2.1.11` is vulnerable. | -| `dotnet list .\Dapper.FluentMap.sln package --deprecated --no-restore` | Passed. Only `xunit 2.9.3` is reported as Legacy with `xunit.v3` alternative, deferred to Delivery 05. | -| PyYAML parse of `.github/workflows/ci.yml`, `.appveyor.yml`, `.travis.yml` | Passed. YAML syntax parsed locally. | -| `rg` for publish commands, secrets, `continue-on-error`, `poc-arquitetura`, `.NET Core 3.1`, and VS 2019 in CI files | Passed. No matches. | - -### TargetFramework Confirmation - -| Project | Confirmed target | -|---|---| -| `src/Dapper.FluentMap/Dapper.FluentMap.csproj` | `TargetFrameworks=netstandard2.0` | -| `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj` | `TargetFrameworks=netstandard2.0` | -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | `TargetFramework=net10.0` | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | `TargetFramework=net10.0` | - -### Generated Packages - -Generated under `artifacts/packages/`: - -- `Dapper.FluentMap.2.0.0.nupkg` -- `Dapper.FluentMap.Dommel.2.0.0.nupkg` - -Package contents inspected: - -| Package | Expected contents | Dependency group | -|---|---|---| -| `Dapper.FluentMap.2.0.0.nupkg` | `lib/netstandard2.0/Dapper.FluentMap.dll`, `lib/netstandard2.0/Dapper.FluentMap.xml`, `.nuspec`, package metadata files. | `.NETStandard2.0`: `Dapper 2.1.79`. | -| `Dapper.FluentMap.Dommel.2.0.0.nupkg` | `lib/netstandard2.0/Dapper.FluentMap.Dommel.dll`, `lib/netstandard2.0/Dapper.FluentMap.Dommel.xml`, `.nuspec`, package metadata files. | `.NETStandard2.0`: `Dapper.FluentMap 2.0.0`, `Dapper 2.1.79`, `Dommel 3.5.3`. | - -Package inspection confirmed: - -- `lib/netstandard2.0` exists in both packages. -- expected assemblies and XML documentation files are present. -- no test assemblies are present. -- no `bin/`, `obj/`, local cache, local path, source tree artifact, or secret file is present. -- package version remains `2.0.0`. -- package metadata remains consistent with existing project files. -- no symbols or SourceLink files are included; none were configured before this delivery. -- license is represented by existing `licenseUrl`, which now produces NU5125 but was not modernized to avoid unrelated package metadata churn. -- package README is not included; NuGet reports a recommendation, not a packaging failure. - -### CI Validation - -Local validation of CI files: - -- YAML syntax parses for GitHub Actions, AppVeyor and Travis files. -- paths reference the real solution and artifact directory. -- CI commands match the locally validated Release sequence. -- no CI file publishes to NuGet. -- no CI file adds tokens or secrets. -- no CI file uses `continue-on-error`. -- no CI file references `poc-arquitetura`. -- no CI file references `netcoreapp3.1`, `.NET Core 3.1`, or `Visual Studio 2019`. -- tests for both core and Dommel are run through the solution. -- package generation is controlled and artifacts are stored, not published. - -GitHub Actions was not executed remotely in this delivery. AppVeyor and Travis were also not executed remotely. The validation here is local YAML parsing plus command equivalence to the local successful build/test/pack sequence. - -### Dependency Review - -No direct dependency changes were required in this delivery. - -Confirmed: - -- no direct package downgrade was reported. -- no vulnerable packages are reported in `src/`. -- `Dapper 2.1.79` and `Dommel 3.5.3` remain compatible with `netstandard2.0` package outputs. -- test packages restore and run on `net10.0`. -- xUnit remains on `2.9.3` for Delivery 05. - -Deferred: - -- `xunit` -> `xunit.v3` migration remains Delivery 05. -- `xunit.analyzers 1.18.0` remains a transitive package to xUnit 2 and is deferred with xUnit 3 migration. -- `SQLitePCLRaw.lib.e_sqlite3 2.1.11` remains a vulnerable test transitive from `Microsoft.Data.Sqlite 10.0.10`; this should be handled by a dedicated dependency-hardening task unless Delivery 05 changes the graph naturally. - -### Limitations - -- CI was not run on GitHub/AppVeyor/Travis from this environment. -- Travis availability and image contents were not proven remotely. -- AppVeyor installation of .NET 10 depends on network access to `https://dot.net/v1/dotnet-install.ps1`. -- NuGet package metadata modernization (`PackageLicenseExpression`, README, SourceLink/repository URL metadata) was intentionally not performed because it is outside the migration validation scope. diff --git a/docs/sdd/net10-migration/05-xunit3-migration.md b/docs/sdd/net10-migration/05-xunit3-migration.md deleted file mode 100644 index 874490a..0000000 --- a/docs/sdd/net10-migration/05-xunit3-migration.md +++ /dev/null @@ -1,363 +0,0 @@ -# 05 - xUnit 3 Migration - -## Specification - -Migrate the test projects from xUnit 2 to xUnit 3 as the final, isolated delivery of the .NET 10 migration. - -Scope: - -- test projects remain on `net10.0`; -- source projects remain on `netstandard2.0`; -- replace xUnit 2 infrastructure with xUnit 3 infrastructure; -- preserve the existing test scenarios, assertions, traits, and observable behavior; -- keep local `dotnet test`, CI, Test Explorer, and coverage behavior working; -- do not change production code, production dependencies, package metadata, target frameworks, or public API. - -Out of scope: - -- Dapper or Dommel updates; -- broad test refactoring; -- assertion rewrites; -- removing or skipping tests; -- Microsoft Testing Platform adoption unless required; -- publishing packages, pushing the branch, or opening a pull request. - -## Discovery - -### Recovered Context - -- `AGENTS.md` was read before changes. -- Local skills under `.agents/skills/` were checked. -- The requested official `migrate-xunit-to-xunit-v3` skill is referenced by `AGENTS.md`, but is not available in this session and is not present under `.agents/skills/`. -- Skills used: - - `run-tests` for VSTest/xUnit command selection. - - `msbuild-antipatterns` for focused project-file review. -- Required handoff files were read: - - `docs/sdd/net10-migration/README.md` - - `docs/sdd/net10-migration/status.md` - - `docs/sdd/net10-migration/decisions.md` - - `docs/sdd/net10-migration/dependency-matrix.md` - - `docs/sdd/net10-migration/01-inventory-baseline.md` - - `docs/sdd/net10-migration/02-test-projects-net10.md` - - `docs/sdd/net10-migration/03-src-dependencies.md` - - `docs/sdd/net10-migration/04-validation-pack-ci.md` -- Shared branch recorded in `README.md`: `chore/net10-migration`. -- Current branch: `chore/net10-migration`. -- Deliveries 01 through 04 are concluded in `status.md`. - -### Baseline Before Migration - -Environment: - -- Active SDK: `10.0.302`. -- Test platform: VSTest. -- Test framework: xUnit 2. -- No `global.json`. -- No `Directory.Build.props`, `Directory.Build.targets`, or `Directory.Packages.props`. -- No `xunit.runner.json` or `*.runsettings`. -- No `.vscode/` configuration. - -Commands executed before any edit: - -| Command | Result | -|---|---| -| `dotnet restore` | Passed with existing NU1903 warning for transitive `SQLitePCLRaw.lib.e_sqlite3 2.1.11` in `Dapper.FluentMap.Tests`. | -| `dotnet build --configuration Release` | Passed. `src` projects built for `netstandard2.0`; test projects built for `net10.0`. | -| `dotnet test --configuration Release` | Passed. `Dapper.FluentMap.Tests`: 45 passed, 0 failed, 0 skipped, about 271 ms. `Dapper.FluentMap.Dommel.Tests`: 7 passed, 0 failed, 0 skipped, about 103 ms. | -| `dotnet test test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj --configuration Release` | Passed. 45 passed, 0 failed, 0 skipped, about 281 ms. | -| `dotnet test test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj --configuration Release` | Passed. 7 passed, 0 failed, 0 skipped, about 87 ms. | - -Baseline result files: - -- No TRX, coverage, or custom result file was generated by the baseline commands because no logger or collector was requested. - -Baseline test count: - -| Metric | Before | -|---|---:| -| Tests discovered | 52 | -| Tests passed | 52 | -| Tests failed | 0 | -| Tests skipped | 0 | - -### Test Project Inventory - -| Project | Target | Current direct test packages | -|---|---|---| -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | `net10.0` | `Microsoft.NET.Test.Sdk 18.8.1`, `Microsoft.Data.Sqlite 10.0.10`, `xunit 2.9.3`, `xunit.runner.visualstudio 3.1.5` | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | `net10.0` | `Microsoft.NET.Test.Sdk 18.8.1`, `xunit 2.9.3`, `xunit.runner.visualstudio 3.1.5`, `coverlet.collector 10.0.1` | - -Source projects: - -- `src/Dapper.FluentMap`: `TargetFrameworks=netstandard2.0`. -- `src/Dapper.FluentMap.Dommel`: `TargetFrameworks=netstandard2.0`. - -### xUnit Usage Scan - -Patterns found under `test/`: - -- `[Fact]`: 52 tests. -- `[Trait("Category", "Integration")]`: 7 tests in `DapperIntegrationTests`. -- `Assert.Throws`: present and semantically preserved. -- `[assembly: CollectionBehavior(DisableTestParallelization = true)]`: present in both test assemblies. -- `FluentMapper`, `FluentMapper.Reset`, `FluentMapper.EntityMaps`, `FluentMapper.TypeConventions`, `SqlMapper.GetTypeMap`, and Dapper/SQLite integration tests. - -Patterns not found: - -- `[Theory]`, `[InlineData]`, `[MemberData]`, `[ClassData]`; -- `IClassFixture<>`, `ICollectionFixture<>`, `CollectionDefinition`; -- `ITestOutputHelper`; -- custom traits or custom discoverers; -- `Assert.ThrowsAsync`; -- `Skip` attributes; -- `async` test methods; -- reflection over xUnit implementation types. - -### CI and Tooling - -Found: - -- `.github/workflows/ci.yml` restores, builds, tests, packs, and uploads `.nupkg` artifacts with .NET SDK `10.0.x`. -- `.appveyor.yml` installs .NET SDK 10 GA, then restores, builds, tests, packs, and stores artifacts. -- `.travis.yml` uses `dotnet: 10.0` on `jammy`, then restores, builds, tests, and packs. - -Not found: - -- `.vscode/`; -- VS Code tasks or settings; -- runner-specific `xunit.runner.json`; -- MTP configuration through `global.json` or ``. - -### Package Discovery - -Latest stable versions were checked against the NuGet flat container feed on 2026-07-25: - -| Package | Latest stable identified | -|---|---:| -| `xunit.v3` | `3.2.2` | -| `xunit.runner.visualstudio` | `3.1.5` | -| `Microsoft.NET.Test.Sdk` | `18.8.1` | -| `coverlet.collector` | `10.0.1` | - -Additional package diagnostics before migration: - -- `dotnet list package --deprecated --no-restore` reports `xunit 2.9.3` as legacy with `xunit.v3` as the alternative. -- `dotnet list package --outdated --include-transitive --no-restore` reports deferred transitives: - - `SQLitePCLRaw.*` from `Microsoft.Data.Sqlite`; - - `xunit.analyzers 1.18.0`; - - source netstandard transitives already documented in previous deliveries. -- `dotnet list package --vulnerable --include-transitive --no-restore` reports only the known test transitive `SQLitePCLRaw.lib.e_sqlite3 2.1.11` high severity warning. - -## Decision - -### Package Plan - -Remove from both test projects: - -- `xunit 2.9.3`. - -Add to both test projects: - -- `xunit.v3 3.2.2`. - -Keep: - -- `Microsoft.NET.Test.Sdk 18.8.1`; -- `xunit.runner.visualstudio 3.1.5`; -- `coverlet.collector 10.0.1` in the Dommel test project; -- `Microsoft.Data.Sqlite 10.0.10` in the core test project. - -### Runner Strategy - -Use VSTest through `Microsoft.NET.Test.Sdk` and `xunit.runner.visualstudio`. - -Rationale: - -- The repository already uses VSTest successfully with `dotnet test`. -- xUnit.net v3 supports VSTest through the 3.x Visual Studio runner. -- Keeping VSTest is the smallest change and preserves existing CI and Test Explorer behavior. -- Do not introduce Microsoft Testing Platform, `global.json`, or `` in this delivery because they are not required for the migration. - -### Coverage Strategy - -Keep the existing coverlet collector package in `Dapper.FluentMap.Dommel.Tests`. - -The repository has no official runsettings or coverage command beyond collector availability. Validation will run the repository's available coverage path with `dotnet test --collect:"XPlat Code Coverage"` on the project that references `coverlet.collector`. - -### Parallelism Strategy - -Preserve `[assembly: CollectionBehavior(DisableTestParallelization = true)]` in both test assemblies. - -Technical reason: - -- Tests use global mutable state from `FluentMapper`, Dapper type maps, caches, and Dommel resolver state. -- The existing suite is green with parallelization disabled. -- Re-enabling parallelization would be a behavior and isolation change outside this migration. - -### Expected Code Adaptations - -No source or test-code adaptation is expected because the suite uses xUnit surface area that remains compatible: - -- `[Fact]`; -- `[Trait]`; -- `Assert.Equal`, `Assert.Null`, `Assert.NotNull`, `Assert.IsType`, `Assert.Single`, `Assert.Throws`; -- `CollectionBehavior`. - -If compilation or discovery exposes a real xUnit 3 API incompatibility, only the minimal semantic equivalent will be applied. - -### Risks - -- Test discovery could change if the VSTest adapter resolves xUnit 3 differently from xUnit 2. -- Trait display/filter metadata must remain discoverable for integration tests. -- Coverage execution must still load the testhost with the migrated framework package. -- Known SQLitePCLRaw vulnerability is unrelated to xUnit and remains deferred. - -## Delivery - -- Updated `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj`: - - removed `xunit 2.9.3`; - - added `xunit.v3 3.2.2`; - - preserved `Microsoft.NET.Test.Sdk 18.8.1`; - - preserved `xunit.runner.visualstudio 3.1.5`; - - preserved `Microsoft.Data.Sqlite 10.0.10`; - - preserved `TargetFramework net10.0`. -- Updated `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj`: - - removed `xunit 2.9.3`; - - added `xunit.v3 3.2.2`; - - preserved `Microsoft.NET.Test.Sdk 18.8.1`; - - preserved `xunit.runner.visualstudio 3.1.5`; - - preserved `coverlet.collector 10.0.1`; - - preserved `TargetFramework net10.0`. -- No C# source or test file required changes. -- No test was removed, skipped, renamed, or weakened. -- No production project was changed. -- No `global.json`, Microsoft Testing Platform runner setting, `xunit.runner.json`, `.runsettings`, or VS Code config was added. -- CI files did not require command changes because they already run `dotnet test` through VSTest-compatible infrastructure. - -## Validation - -### Commands Executed - -| Command | Result | -|---|---| -| `dotnet restore` | Passed with existing NU1903 warning for transitive `SQLitePCLRaw.lib.e_sqlite3 2.1.11` in `Dapper.FluentMap.Tests`. | -| `dotnet build --configuration Release` immediately after package edit | Passed; no C# adaptation required. | -| `dotnet test --configuration Release` immediately after package edit | Passed. `Dapper.FluentMap.Tests`: 45 passed, 0 failed, 0 skipped, about 393 ms. `Dapper.FluentMap.Dommel.Tests`: 7 passed, 0 failed, 0 skipped, about 165 ms. | -| `dotnet build` | Passed. Debug outputs remain `src`=`netstandard2.0`, `test`=`net10.0`. | -| `dotnet test` | Passed. `Dapper.FluentMap.Tests`: 45 passed, 0 failed, 0 skipped, about 415 ms. `Dapper.FluentMap.Dommel.Tests`: 7 passed, 0 failed, 0 skipped, about 192 ms. | -| `dotnet build --configuration Release` | Passed. Release outputs remain `src`=`netstandard2.0`, `test`=`net10.0`. | -| `dotnet test --configuration Release` | Passed. `Dapper.FluentMap.Tests`: 45 passed, 0 failed, 0 skipped, about 418 ms. `Dapper.FluentMap.Dommel.Tests`: 7 passed, 0 failed, 0 skipped, about 158 ms. | -| `dotnet test test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj --configuration Release` | Passed. 45 passed, 0 failed, 0 skipped, about 537 ms. | -| `dotnet test test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj --configuration Release` | Passed. 7 passed, 0 failed, 0 skipped, about 270 ms. | -| `dotnet test test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj --configuration Release --collect:"XPlat Code Coverage" --results-directory TestResults\coverage-xunit3` | Passed. 7 passed, 0 failed, 0 skipped; generated `TestResults/coverage-xunit3//coverage.cobertura.xml`. | -| `dotnet pack .\Dapper.FluentMap.sln --configuration Release --no-build --output .\artifacts\packages` | Passed. Generated both expected `.nupkg` files. Existing NU5125 `licenseUrl` warning and package README recommendation remain. | -| `dotnet list .\Dapper.FluentMap.sln package --include-transitive --no-restore` | Passed. Test projects resolve `xunit.v3 3.2.2` and no longer resolve xUnit 2 packages. | -| `dotnet list .\Dapper.FluentMap.sln package --outdated --include-transitive --no-restore` | Passed. No outdated direct packages. New xUnit v3 MTP v1 transitives report newer MTP v2 lines, but are transitive to the selected stable xUnit v3 package and not forced directly. | -| `dotnet list .\Dapper.FluentMap.sln package --deprecated --no-restore` | Passed. No deprecated packages reported. | -| `dotnet list .\Dapper.FluentMap.sln package --vulnerable --include-transitive --no-restore` | Passed. Only known test transitive `SQLitePCLRaw.lib.e_sqlite3 2.1.11` remains vulnerable. | -| `dotnet test test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj --configuration Release --list-tests` | Passed. Listed 45 core tests, confirming discovery under xUnit 3/VSTest. | -| `dotnet test test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj --configuration Release --filter "Category=Integration"` | Passed. 7 integration trait tests passed. | - -### Before and After Counts - -| Metric | Before | After | -|---|---:|---:| -| Tests discovered | 52 | 52 | -| Tests passed | 52 | 52 | -| Tests failed | 0 | 0 | -| Tests skipped | 0 | 0 | - -No count changed. The migration preserved all existing test scenarios. - -### Package Graph After Migration - -Final direct test packages: - -| Project | Direct package | Final version | -|---|---|---:| -| `Dapper.FluentMap.Tests` | `Microsoft.NET.Test.Sdk` | `18.8.1` | -| `Dapper.FluentMap.Tests` | `Microsoft.Data.Sqlite` | `10.0.10` | -| `Dapper.FluentMap.Tests` | `xunit.runner.visualstudio` | `3.1.5` | -| `Dapper.FluentMap.Tests` | `xunit.v3` | `3.2.2` | -| `Dapper.FluentMap.Dommel.Tests` | `Microsoft.NET.Test.Sdk` | `18.8.1` | -| `Dapper.FluentMap.Dommel.Tests` | `xunit.runner.visualstudio` | `3.1.5` | -| `Dapper.FluentMap.Dommel.Tests` | `xunit.v3` | `3.2.2` | -| `Dapper.FluentMap.Dommel.Tests` | `coverlet.collector` | `10.0.1` | - -Removed xUnit 2 packages from the test graph: - -- direct `xunit 2.9.3`; -- transitive `xunit.abstractions 2.0.3`; -- transitive `xunit.assert 2.9.3`; -- transitive `xunit.core 2.9.3`; -- transitive `xunit.extensibility.core 2.9.3`; -- transitive `xunit.extensibility.execution 2.9.3`. - -Added xUnit 3 graph: - -- direct `xunit.v3 3.2.2`; -- transitive `xunit.v3.assert 3.2.2`; -- transitive `xunit.v3.common 3.2.2`; -- transitive `xunit.v3.core.mtp-v1 3.2.2`; -- transitive `xunit.v3.extensibility.core 3.2.2`; -- transitive `xunit.v3.mtp-v1 3.2.2`; -- transitive `xunit.v3.runner.common 3.2.2`; -- transitive `xunit.v3.runner.inproc.console 3.2.2`; -- transitive `xunit.analyzers 1.27.0`. - -### Coverage - -Coverage collector validation passed for the only test project that references `coverlet.collector`: - -- command: `dotnet test test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj --configuration Release --collect:"XPlat Code Coverage" --results-directory TestResults\coverage-xunit3`; -- generated: `TestResults/coverage-xunit3//coverage.cobertura.xml`; -- result: 7 passed, 0 failed, 0 skipped. - -The core test project still has no `coverlet.collector` reference or repository runsettings. That preexisting shape was preserved. - -### CI and VS Code - -CI files were reviewed: - -- `.github/workflows/ci.yml` still restores, builds, tests, packs, and uploads artifacts with .NET SDK `10.0.x`. -- `.appveyor.yml` still installs .NET SDK 10 GA and runs restore/build/test/pack. -- `.travis.yml` still uses `dotnet: 10.0` on `jammy` and runs restore/build/test/pack. -- No CI file references `netcoreapp3.1`, `.NET Core 3.1`, `Visual Studio 2019`, `dotnet nuget push`, package publish tokens, or `continue-on-error`. - -VS Code: - -- No `.vscode/` directory exists in the repository. -- Test Explorer was not opened from this environment. -- The VSTest adapter path was validated through `dotnet test`, `--list-tests`, and trait filtering, which is the same runner family used by VS Code C# test discovery. - -### TargetFramework Confirmation - -| Project | Final target | -|---|---| -| `src/Dapper.FluentMap/Dapper.FluentMap.csproj` | `TargetFrameworks=netstandard2.0` | -| `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj` | `TargetFrameworks=netstandard2.0` | -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | `TargetFramework=net10.0` | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | `TargetFramework=net10.0` | - -### Package Inspection - -Generated packages: - -- `artifacts/packages/Dapper.FluentMap.2.0.0.nupkg` -- `artifacts/packages/Dapper.FluentMap.Dommel.2.0.0.nupkg` - -Package contents remain limited to package metadata plus: - -- `lib/netstandard2.0/Dapper.FluentMap.dll` -- `lib/netstandard2.0/Dapper.FluentMap.xml` -- `lib/netstandard2.0/Dapper.FluentMap.Dommel.dll` -- `lib/netstandard2.0/Dapper.FluentMap.Dommel.xml` - -No test assemblies, local paths, secrets, `bin/`, `obj/`, `TestResults/`, or `.nupkg` source artifacts were found inside the packages. - -### Limitations and Deferred Items - -- Remote GitHub Actions, AppVeyor, and Travis runs were not executed from this environment. -- VS Code Test Explorer was not opened interactively; runner compatibility was validated through VSTest command-line discovery and execution. -- `SQLitePCLRaw.lib.e_sqlite3 2.1.11` remains a vulnerable transitive dependency in the core test project. This is unrelated to xUnit and remains deferred to a dependency-hardening task. -- xUnit v3 `3.2.2` resolves Microsoft Testing Platform v1 transitives by default. The repository intentionally remains on VSTest for `dotnet test`; MTP v2 adoption would be a separate runner migration. diff --git a/docs/sdd/net10-migration/README.md b/docs/sdd/net10-migration/README.md deleted file mode 100644 index 3b08e52..0000000 --- a/docs/sdd/net10-migration/README.md +++ /dev/null @@ -1,132 +0,0 @@ -# .NET 10 test migration - -## Objective - -Document and coordinate the migration of the test projects from `netcoreapp3.1` to `net10.0`, with controlled dependency updates for `src/` and `test/` projects. - -The migration must preserve the published library targets: - -- `src/Dapper.FluentMap` stays on `netstandard2.0`. -- `src/Dapper.FluentMap.Dommel` stays on `netstandard2.0`. -- The `src/` projects must remain consumable by `net10.0` applications and tests. - -This folder is the persistent handoff source for the five independent chats. Future deliveries must read these files instead of relying on memory from previous conversations. - -## Shared Branch - -Branch: `chore/net10-migration` - -Do not push this branch unless a later prompt explicitly asks for it. - -## Expected Final Structure - -```text -src/ -|-- Dapper.FluentMap -> netstandard2.0 -`-- Dapper.FluentMap.Dommel -> netstandard2.0 - -test/ -|-- Dapper.FluentMap.Tests -> net10.0 -`-- Dapper.FluentMap.Dommel.Tests -> net10.0 -``` - -## Delivery Order - -1. Inventory and baseline. -2. Migrate test projects to `net10.0`. -3. Update `src/` project dependencies. -4. Complete validation, package inspection, and CI review. -5. Separate migration to xUnit 3. - -## Identified Solution and Projects - -Solution: - -- `Dapper.FluentMap.sln` - -Projects: - -- `src/Dapper.FluentMap/Dapper.FluentMap.csproj` -- `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj` -- `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` -- `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` - -## Repository Validation Commands - -Commands documented in `AGENTS.md` for the main library: - -```bash -dotnet restore ./Dapper.FluentMap.sln -dotnet build ./src/Dapper.FluentMap/Dapper.FluentMap.csproj --configuration Release --no-restore -dotnet test ./test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj --configuration Release -``` - -Commands documented in `AGENTS.md` for the full solution: - -```bash -dotnet restore ./Dapper.FluentMap.sln -dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore -dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build -``` - -Commands documented in `AGENTS.md` for packaging: - -```bash -dotnet pack ./src/Dapper.FluentMap/Dapper.FluentMap.csproj --configuration Release --no-build --output ./artifacts/packages -``` - -CI files after Delivery 04: - -- `.github/workflows/ci.yml`: installs .NET SDK `10.0.x` GA, runs restore, Release build, Release tests, Release pack, and uploads `.nupkg` artifacts. -- `.appveyor.yml`: installs .NET SDK 10 GA on Visual Studio 2022 image, runs restore, Release build, Release tests, Release pack, and stores `.nupkg` artifacts. -- `.travis.yml`: uses `dotnet: 10.0` on `jammy`, runs restore, Release build, Release tests, and Release pack. - -No `global.json`, `Directory.Build.props`, `Directory.Packages.props`, or `.editorconfig` files are present after Delivery 04. - -## Resultado final - -TargetFrameworks finais: - -- `src/Dapper.FluentMap`: `netstandard2.0` -- `src/Dapper.FluentMap.Dommel`: `netstandard2.0` -- `test/Dapper.FluentMap.Tests`: `net10.0` -- `test/Dapper.FluentMap.Dommel.Tests`: `net10.0` - -Versoes principais: - -- .NET SDK local validado: `10.0.302` -- `Dapper`: `2.1.79` -- `Dommel`: `3.5.3` -- `Microsoft.NET.Test.Sdk`: `18.8.1` -- `xunit.v3`: `3.2.2` -- `xunit.runner.visualstudio`: `3.1.5` -- `coverlet.collector`: `10.0.1` - -Runner de testes: - -- VSTest via `Microsoft.NET.Test.Sdk` e `xunit.runner.visualstudio`. -- Microsoft Testing Platform nao foi adotado. -- Paralelismo de testes permanece desabilitado por estado global compartilhado. - -Status: - -- build Debug: aprovado. -- build Release: aprovado. -- testes Debug: 52 descobertos, 52 aprovados, 0 falhos, 0 ignorados. -- testes Release: 52 descobertos, 52 aprovados, 0 falhos, 0 ignorados. -- pack Release: aprovado; pacotes emitidos com `lib/netstandard2.0`. -- CI: arquivos GitHub Actions, AppVeyor e Travis revisados localmente; execucoes remotas nao foram realizadas. - -Dependencias bloqueadas, deferidas ou concluidas posteriormente: - -- `SQLitePCLRaw.lib.e_sqlite3 2.1.11` foi corrigido em hardening dedicado documentado em `docs/sdd/security-hardening/sqlitepclraw-vulnerability.md`; `Dapper.FluentMap.Tests` agora pina `SQLitePCLRaw.bundle_e_sqlite3 2.1.12` com `PrivateAssets="all"`. -- Modernizacao de metadados NuGet (`licenseUrl`, README de pacote, SourceLink/repository metadata) permanece fora do escopo. -- Adocao de Microsoft Testing Platform fica deferida para uma migracao de runner separada, se necessaria. - -Referencias dos relatorios: - -- `docs/sdd/net10-migration/01-inventory-baseline.md` -- `docs/sdd/net10-migration/02-test-projects-net10.md` -- `docs/sdd/net10-migration/03-src-dependencies.md` -- `docs/sdd/net10-migration/04-validation-pack-ci.md` -- `docs/sdd/net10-migration/05-xunit3-migration.md` diff --git a/docs/sdd/net10-migration/decisions.md b/docs/sdd/net10-migration/decisions.md deleted file mode 100644 index 4a43bf5..0000000 --- a/docs/sdd/net10-migration/decisions.md +++ /dev/null @@ -1,99 +0,0 @@ -# Cross-Delivery Decisions - -## Preserve `netstandard2.0` for Published Projects - -`src/Dapper.FluentMap` and `src/Dapper.FluentMap.Dommel` must remain on `netstandard2.0` throughout the migration. Dependency updates in Delivery 03 must not force either published project to move to `net8.0`, `net10.0`, or multi-targeting. - -## Isolate xUnit 3 Until Delivery 05 - -Delivery 02 may update xUnit 2 packages to the latest stable xUnit 2 line, but must not migrate test code or project references to `xunit.v3`. The xUnit 3 migration is intentionally isolated in Delivery 05. - -## Separate Test Runtime Migration From Source Dependency Updates - -Delivery 02 should focus on test projects and test-only packages needed for `net10.0`. Delivery 03 should focus on direct dependencies of `src/` projects (`Dapper` and `Dommel`) after the tests can run on a supported runtime. - -## Use `net10.0` Tests as Consumer Validation - -The primary compatibility check for the published `netstandard2.0` projects is to run the `net10.0` test projects while they reference the `src/` projects. Delivery 04 should add package inspection with `dotnet pack` to confirm the published assemblies and dependency groups still target `netstandard2.0`. - -## Delivery 02 Test Runtime Migration - -Delivery 02 migrated only the test projects to `net10.0` and changed their single-target element from `TargetFrameworks` to `TargetFramework`. The `src/` projects were not normalized in this delivery and remain on `TargetFrameworks netstandard2.0` to avoid unrelated published-project churn. - -Test execution remains on VSTest with xUnit 2: - -- no `global.json` Microsoft Testing Platform runner was introduced; -- no `TestingPlatformDotnetTestSupport` property was introduced; -- `xunit` was updated only within the xUnit 2 package line; -- `xunit.runner.visualstudio 3.1.5` was selected because it is a modern VSTest adapter that supports .NET 8+ and can run xUnit 2 tests; -- `xunit.v3` remains deferred to Delivery 05. - -`Microsoft.Data.Sqlite` was updated only in the core test project because it is a direct test-only dependency used by Dapper integration tests. A vulnerable transitive `SQLitePCLRaw.lib.e_sqlite3 2.1.11` remains after the update and should be reviewed in Delivery 04 or a dedicated dependency-hardening task rather than adding unrelated overrides in this migration step. - -Dedicated security hardening later completed that review in `docs/sdd/security-hardening/sqlitepclraw-vulnerability.md`: `Dapper.FluentMap.Tests` now pins `SQLitePCLRaw.bundle_e_sqlite3 2.1.12` with `PrivateAssets="all"`, removing vulnerable `SQLitePCLRaw.lib.e_sqlite3 2.1.11` while keeping SQLite test-only. - -## Delivery 03 Production Dependency Updates - -Delivery 03 updated only direct production dependencies in `src/`: - -- `Dapper` was updated from `2.0.35` to `2.1.79` in both published projects. -- `Dommel` was updated from `2.0.0` to `3.5.3` in `src/Dapper.FluentMap.Dommel`. -- Both `src/` projects remain on `TargetFrameworks netstandard2.0`. -- Both test projects remain on `TargetFramework net10.0`. - -No C# code changes were required. Compilation confirmed that the Dapper type-map API surface used by `Dapper.FluentMap` and the Dommel resolver API surface used by `Dapper.FluentMap.Dommel` remain source-compatible for this repository. - -The update was intentionally limited to direct production dependencies. Transitively outdated packages reported after the update were not forced as direct references: - -- `Microsoft.Bcl.AsyncInterfaces 10.0.8` is resolved through Dapper's `netstandard2.0` dependency floor. -- `Microsoft.NETCore.Platforms 1.1.0` remains part of the `NETStandard.Library` restore graph. -- `xunit.analyzers 1.18.0` remains transitive to xUnit 2 and is deferred with the xUnit 3 migration. -- `SQLitePCLRaw.*` remains test-only and was covered by the dedicated dependency-hardening task documented in `docs/sdd/security-hardening/sqlitepclraw-vulnerability.md`. - -Delivery 04 should pack and inspect the published packages to confirm the final dependency groups and package contents. - -## Delivery 04 Validation and CI - -Delivery 04 validated the final migration state without changing production code, public API, target frameworks, package versions, or package metadata. - -Final target matrix: - -- `src/Dapper.FluentMap`: `netstandard2.0` -- `src/Dapper.FluentMap.Dommel`: `netstandard2.0` -- `test/Dapper.FluentMap.Tests`: `net10.0` -- `test/Dapper.FluentMap.Dommel.Tests`: `net10.0` - -No `global.json` was created. CI installs/selects .NET 10 explicitly, while the repository avoids pinning a short-lived exact SDK in source control. - -CI policy: - -- run restore, Release build, Release tests and Release pack. -- store `.nupkg` files only as CI artifacts. -- do not run `dotnet nuget push`. -- do not configure NuGet API keys, publish tokens, package feeds, release uploads, or `continue-on-error`. -- do not add a framework matrix because tests only target `net10.0`. - -Package inspection confirms both published packages contain only `lib/netstandard2.0` assemblies/XML docs plus package metadata. Existing `licenseUrl` metadata produces NU5125 and package README is recommended by NuGet, but both are deferred because this delivery must avoid unrelated metadata modernization. - -## Delivery 05 xUnit 3 Migration - -Delivery 05 migrated the test projects from xUnit 2 to xUnit 3 without changing test semantics or production code. - -Final test infrastructure: - -- `Microsoft.NET.Test.Sdk 18.8.1` -- `xunit.v3 3.2.2` -- `xunit.runner.visualstudio 3.1.5` -- `coverlet.collector 10.0.1` in `Dapper.FluentMap.Dommel.Tests` -- no Microsoft Testing Platform runner in `global.json` -- no `` property -- test assemblies disable parallel execution with `[assembly: CollectionBehavior(DisableTestParallelization = true)]` because the suite uses global FluentMapper/Dapper state - -Permanent decisions: - -- VSTest remains the official `dotnet test` runner path for this repository. -- `xunit.runner.visualstudio` remains referenced for `dotnet test`, Visual Studio, and VS Code Test Explorer compatibility. -- Microsoft Testing Platform adoption is deferred to a separate runner migration, if ever needed. -- Parallel execution remains disabled until FluentMapper, Dapper type-map, and Dommel resolver global state are isolated. -- Coverage remains based on the existing Coverlet collector setup; no Microsoft Testing Platform coverage extension was introduced. -- Existing Dapper materialization and Dommel resolver tests remain active as compatibility proof. diff --git a/docs/sdd/net10-migration/dependency-matrix.md b/docs/sdd/net10-migration/dependency-matrix.md deleted file mode 100644 index c971ca7..0000000 --- a/docs/sdd/net10-migration/dependency-matrix.md +++ /dev/null @@ -1,128 +0,0 @@ -# Dependency Matrix - -Latest stable versions were identified with `dotnet list package --outdated --include-transitive --no-restore` after an isolated restore, and cross-checked against NuGet.org package pages on 2026-07-25. - -## Projects and Direct Dependencies - -| Project | Type | Current TFM | Desired TFM | Project references | Direct package | Current version | Latest stable identified | Declared `netstandard2.0` compatibility | `net10.0` consumer compatibility | Planned action | Notes / blocks | -|---|---|---|---|---|---|---:|---:|---|---|---|---| -| `src/Dapper.FluentMap/Dapper.FluentMap.csproj` | Published library | `netstandard2.0` | `netstandard2.0` | - | `Dapper` | `2.1.79` | `2.1.79` | Yes; latest includes `netstandard2.0` assets. | Yes; latest declares `net10.0` compatibility. | Completed in Delivery 03. | Updated from `2.0.35`; no code changes required; Dapper type-map integration tests passed. | -| `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj` | Published library / integration | `netstandard2.0` | `netstandard2.0` | `src/Dapper.FluentMap/Dapper.FluentMap.csproj` | `Dapper` | `2.1.79` | `2.1.79` | Yes; latest includes `netstandard2.0` assets. | Yes; latest declares `net10.0` compatibility. | Completed in Delivery 03. | Updated from `2.0.35`; kept aligned with core and Dommel dependency floor. | -| `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj` | Published library / integration | `netstandard2.0` | `netstandard2.0` | `src/Dapper.FluentMap/Dapper.FluentMap.csproj` | `Dommel` | `3.5.3` | `3.5.3` | Yes; latest includes `netstandard2.0` assets. | Yes; latest declares `net10.0` compatibility. | Completed in Delivery 03. | Updated from `2.0.0`; major update compiled without resolver code changes and Dommel tests passed. | -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | Test project | `net10.0` | `net10.0` | `src/Dapper.FluentMap/Dapper.FluentMap.csproj` | `Microsoft.NET.Test.Sdk` | `18.8.1` | `18.8.1` | Test-only; latest declares `netstandard2.0`, `net8.0`, and computed `net10.0` compatibility. | Yes. | Completed in Delivery 02. | Updated from `16.7.1`; VSTest execution passed on `net10.0`. | -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | Test project | `net10.0` | `net10.0` | `src/Dapper.FluentMap/Dapper.FluentMap.csproj` | `Microsoft.Data.Sqlite` | `10.0.10` | `10.0.10` | Yes; current includes `netstandard2.0` assets. | Yes; current has computed `net10.0` compatibility. | Completed in Delivery 02. | Updated from `3.1.32`; transitive `SQLitePCLRaw.lib.e_sqlite3 2.1.11` was later corrected by security hardening with an explicit test-only bundle pin. | -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | Test project | `net10.0` | `net10.0` | `src/Dapper.FluentMap/Dapper.FluentMap.csproj` | `SQLitePCLRaw.bundle_e_sqlite3` | `2.1.12` | `3.0.4` | Yes; `2.1.12` declares `netstandard2.0` compatibility. | Yes; `2.1.12` has computed `net10.0` compatibility. | Completed in security hardening. | Direct test-only pin with `PrivateAssets="all"` to force `SQLitePCLRaw.lib.e_sqlite3 2.1.12` and remove `GHSA-2m69-gcr7-jv3q`; major `3.x` avoided as unnecessary. | -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | Test project | `net10.0` | `net10.0` | `src/Dapper.FluentMap/Dapper.FluentMap.csproj` | `xunit.v3` | `3.2.2` | `3.2.2` | Test-only framework package; xUnit v3 supports modern .NET test projects. | Yes. | Completed in Delivery 05. | Replaced `xunit 2.9.3`; no test code changes required. | -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | Test project | `net10.0` | `net10.0` | `src/Dapper.FluentMap/Dapper.FluentMap.csproj` | `xunit.runner.visualstudio` | `3.1.5` | `3.1.5` | Test adapter; latest does not declare `netstandard2.0`, but that is not required for published `src` packages. | Yes; latest supports .NET 8+ and computed `net10.0`; can run xUnit v1/v2/v3 tests. | Completed in Delivery 05. | Kept as VSTest runner for `dotnet test` and Test Explorer compatibility. | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | Test project | `net10.0` | `net10.0` | `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj` | `Microsoft.NET.Test.Sdk` | `18.8.1` | `18.8.1` | Test-only; latest declares `netstandard2.0`, `net8.0`, and computed `net10.0` compatibility. | Yes. | Completed in Delivery 02. | Updated from `16.7.1`; VSTest execution passed on `net10.0`. | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | Test project | `net10.0` | `net10.0` | `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj` | `xunit.v3` | `3.2.2` | `3.2.2` | Test-only framework package; xUnit v3 supports modern .NET test projects. | Yes. | Completed in Delivery 05. | Replaced `xunit 2.9.3`; no test code changes required. | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | Test project | `net10.0` | `net10.0` | `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj` | `xunit.runner.visualstudio` | `3.1.5` | `3.1.5` | Test adapter; latest does not declare `netstandard2.0`, but that is not required for published `src` packages. | Yes; latest supports .NET 8+ and computed `net10.0`; can run xUnit v1/v2/v3 tests. | Completed in Delivery 05. | Kept as VSTest runner for `dotnet test` and Test Explorer compatibility. | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | Test project | `net10.0` | `net10.0` | `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj` | `coverlet.collector` | `10.0.1` | `10.0.1` | Test/coverage collector; latest does not declare `netstandard2.0`, but it is not a published dependency. | Yes; latest supports .NET 8+ / .NET Framework 4.7.2+ and declares `net10.0` compatibility. | Completed in Delivery 02. | Updated from `1.3.0`; repo still has no runsettings. | - -## Relevant Transitive Findings - -| Area | Package | Resolved version | Latest stable identified | Finding | Planned handling | -|---|---|---:|---:|---|---| -| Test transitives | `Newtonsoft.Json` | Not resolved after Delivery 02 | `13.0.4` | Was vulnerable in old test platform graph. | Resolved by Delivery 02 test package updates. | -| Test transitives | `System.Net.Http` | Not resolved after Delivery 02 | `4.3.4` | Was vulnerable in old `netcoreapp3.1` graph. | Resolved by Delivery 02 test package updates. | -| Test transitives | `System.Text.RegularExpressions` | Not resolved after Delivery 02 | `4.3.1` | Was vulnerable in old `netcoreapp3.1` graph. | Resolved by Delivery 02 test package updates. | -| Core/test transitives | `Microsoft.NETCore.Targets` | `1.1.0` | `5.0.0` | Old transitive package involved in the corrupted global cache error. | Do not edit directly; should disappear from modern test graph where possible. | -| Core/test transitives | `Microsoft.NETCore.Platforms` | `1.1.0` | `7.0.4` | Old transitive from `NETStandard.Library` graph. | Do not edit directly. | -| Source transitives | `Microsoft.Bcl.AsyncInterfaces` | `10.0.8` | `10.0.10` | Transitive dependency resolved through `Dapper 2.1.79` for `netstandard2.0`. | Do not force as a direct source dependency; restore selected Dapper's dependency floor. | -| Dommel transitives | `System.ComponentModel.Annotations` | `5.0.0` | `5.0.0` | Updated naturally through `Dommel 3.5.3`. | Completed by Delivery 03 without direct override. | -| Dommel transitives | `Microsoft.Bcl.HashCode` | `6.0.0` | `6.0.0` | New `Dommel 3.5.3` transitive dependency for `netstandard2.0`. | Accept as package metadata dependency; no direct override. | -| Core tests | `SQLitePCLRaw.lib.e_sqlite3` | `2.1.12` | `3.53.3` | `2.1.11` was vulnerable after `Microsoft.Data.Sqlite 10.0.10`; hardening pins the bundle to resolve `2.1.12` instead. | Completed in `docs/sdd/security-hardening/sqlitepclraw-vulnerability.md`; no vulnerable SQLitePCLRaw package remains in audit. | -| Test transitives | `xunit.analyzers` | `1.27.0` | `1.27.0` | Updated transitively by xUnit 3 in Delivery 05. | No direct override needed. | -| Test transitives | `Microsoft.Testing.Platform` | `1.9.1` | `2.3.2` | Introduced transitively by the default xUnit v3 `3.x` MTP v1 package graph. | Do not force directly; repository uses VSTest for `dotnet test`. | - -## Delivery 02 Applied Updates - -| Project | Package | Previous version | New version | Status | -|---|---|---:|---:|---| -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | `TargetFramework` | `netcoreapp3.1` | `net10.0` | Completed. | -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | `Microsoft.NET.Test.Sdk` | `16.7.1` | `18.8.1` | Completed. | -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | `Microsoft.Data.Sqlite` | `3.1.32` | `10.0.10` | Completed. | -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | `xunit` | `2.4.1` | `2.9.3` | Completed; xUnit 3 deferred. | -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | `xunit.runner.visualstudio` | `2.4.3` | `3.1.5` | Completed. | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | `TargetFramework` | `netcoreapp3.1` | `net10.0` | Completed. | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | `Microsoft.NET.Test.Sdk` | `16.7.1` | `18.8.1` | Completed. | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | `xunit` | `2.4.1` | `2.9.3` | Completed; xUnit 3 deferred. | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | `xunit.runner.visualstudio` | `2.4.3` | `3.1.5` | Completed. | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | `coverlet.collector` | `1.3.0` | `10.0.1` | Completed. | - -## Delivery 03 Applied Updates - -| Project | Package | Previous version | New version | Status | -|---|---|---:|---:|---| -| `src/Dapper.FluentMap/Dapper.FluentMap.csproj` | `Dapper` | `2.0.35` | `2.1.79` | Completed. | -| `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj` | `Dapper` | `2.0.35` | `2.1.79` | Completed. | -| `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj` | `Dommel` | `2.0.0` | `3.5.3` | Completed. | - -## Delivery 05 Applied Updates - -| Project | Package | Previous version | New version | Status | -|---|---|---:|---:|---| -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | `xunit` -> `xunit.v3` | `2.9.3` | `3.2.2` | Completed. | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | `xunit` -> `xunit.v3` | `2.9.3` | `3.2.2` | Completed. | -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | `xunit.runner.visualstudio` | `3.1.5` | `3.1.5` | Kept for VSTest and Test Explorer compatibility. | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | `xunit.runner.visualstudio` | `3.1.5` | `3.1.5` | Kept for VSTest and Test Explorer compatibility. | - -## Security Hardening Applied Updates - -| Project | Package | Previous resolved version | New requested/resolved version | Status | -|---|---|---:|---:|---| -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | `SQLitePCLRaw.bundle_e_sqlite3` | `2.1.11` transitively through `Microsoft.Data.Sqlite` | `2.1.12` direct test-only pin | Completed; `PrivateAssets="all"` keeps the override private to tests. | -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | `SQLitePCLRaw.lib.e_sqlite3` | `2.1.11` vulnerable transitive | `2.1.12` transitive through the pinned bundle | Completed; NuGet audit no longer reports `GHSA-2m69-gcr7-jv3q`. | - -## Pending Direct Package Work - -| Delivery | Package | Current version | Latest stable identified | Reason deferred | -|---|---|---:|---:|---| -| - | - | - | - | No pending direct package work remains in this .NET 10 migration initiative. | - -## Delivery 04 Validation Findings - -No direct package version changed in Delivery 04. - -Validation commands confirmed: - -- restore succeeds with the current package graph. -- Debug and Release builds succeed. -- Release package generation succeeds. -- no direct package downgrade is reported. -- no vulnerable packages are reported in `src/`. -- both `src` packages are emitted under `lib/netstandard2.0`. -- both `net10.0` test projects consume the `netstandard2.0` source projects through `ProjectReference`. - -NuGet previously reported only deferred items already known from earlier deliveries. Security hardening has since resolved the SQLitePCLRaw item: - -- `SQLitePCLRaw.lib.e_sqlite3 2.1.11` was a vulnerable transitive in `Dapper.FluentMap.Tests`; it is now resolved as `2.1.12`. -- xUnit 2 legacy packages are removed by Delivery 05. -- xUnit v3 introduces Microsoft Testing Platform v1 transitives through its default `3.x` package graph. They are not direct dependencies and are not forced because the repository continues to use VSTest for `dotnet test`. -- `Microsoft.Bcl.AsyncInterfaces 10.0.8` remains Dapper's resolved `netstandard2.0` dependency floor. -- `Microsoft.NETCore.Platforms 1.1.0` remains part of the `NETStandard.Library` restore graph. - -Delivery 04 did not force transitive overrides because none are required to validate the .NET 10 migration and package contents. - -## Packages Whose Latest Version Does Not Support `netstandard2.0` - -No direct production dependency updated in Delivery 03 was blocked by `netstandard2.0`. - -Test-only packages that do not need to support the published `netstandard2.0` libraries: - -- `xunit.v3` latest supports the repository's `net10.0` test projects. -- `xunit.runner.visualstudio` latest targets .NET 8+ and .NET Framework 4.7.2+. -- `coverlet.collector` latest supports .NET Core 8+ and .NET Framework 4.7.2+. - -## Package Source References - -- NuGet package source: `https://api.nuget.org/v3/index.json` -- `Dapper`: https://www.nuget.org/packages/Dapper -- `Dommel`: https://www.nuget.org/packages/Dommel/3.5.3 -- `Microsoft.Data.Sqlite`: https://www.nuget.org/packages/Microsoft.Data.Sqlite/10.0.10 -- `SQLitePCLRaw.bundle_e_sqlite3`: https://www.nuget.org/packages/SQLitePCLRaw.bundle_e_sqlite3/2.1.12 -- `SQLitePCLRaw.lib.e_sqlite3`: https://www.nuget.org/packages/SQLitePCLRaw.lib.e_sqlite3/2.1.12 -- `Microsoft.NET.Test.Sdk`: https://www.nuget.org/packages/Microsoft.NET.Test.Sdk/18.8.1 -- `xunit.v3`: https://www.nuget.org/packages/xunit.v3 -- `xunit.runner.visualstudio`: https://www.nuget.org/packages/xunit.runner.visualstudio -- `coverlet.collector`: https://www.nuget.org/packages/coverlet.collector diff --git a/docs/sdd/net10-migration/status.md b/docs/sdd/net10-migration/status.md deleted file mode 100644 index dd505f6..0000000 --- a/docs/sdd/net10-migration/status.md +++ /dev/null @@ -1,9 +0,0 @@ -# Migration Status - -| Entrega | Status | Commit | -|---|---|---| -| 01 - Inventario e baseline | Concluido | docs: document .NET 10 migration baseline | -| 02 - Projetos de teste em net10.0 | Concluido | test: migrate test projects to net10.0 | -| 03 - Dependencias dos projetos de src | Concluido | chore: update production dependencies | -| 04 - Validacao, pack e CI | Concluido | ci: validate .NET 10 build and packaging | -| 05 - Migracao para xUnit 3 | Concluido | test: migrate test suite to xUnit 3 | diff --git a/docs/sdd/security-hardening/sqlitepclraw-vulnerability.md b/docs/sdd/security-hardening/sqlitepclraw-vulnerability.md deleted file mode 100644 index c9523b2..0000000 --- a/docs/sdd/security-hardening/sqlitepclraw-vulnerability.md +++ /dev/null @@ -1,156 +0,0 @@ -## Specification - -Corrigir de forma isolada o alerta de vulnerabilidade de `SQLitePCLRaw.lib.e_sqlite3 2.1.11` resolvido apenas pela infraestrutura de testes SQLite do projeto `Dapper.FluentMap.Tests`. - -A correcao deve: - -- permanecer restrita a projetos de teste; -- nao alterar projetos em `src/`; -- nao alterar API publica ou comportamento funcional do FluentMap; -- nao atualizar dependencias nao relacionadas; -- remover `SQLitePCLRaw.lib.e_sqlite3 2.1.11` do grafo restaurado; -- manter os testes de integracao SQLite funcionando; -- confirmar que dependencias SQLite nao aparecem no pacote NuGet de producao. - -## Discovery - -Contexto recuperado: - -- `AGENTS.md` foi lido antes das alteracoes. -- Skills locais em `.agents/skills/` foram verificadas. -- Skills usadas: - - `msbuild-antipatterns`, para revisar a alteracao de `PackageReference` e uso de `PrivateAssets`; - - `run-tests`, para confirmar o runner VSTest/xUnit v3 e comandos de validacao. -- Documentos de handoff lidos: - - `docs/sdd/net10-migration/README.md` - - `docs/sdd/net10-migration/status.md` - - `docs/sdd/net10-migration/decisions.md` - - `docs/sdd/net10-migration/dependency-matrix.md` - - `docs/sdd/net10-migration/02-test-projects-net10.md` - - `docs/sdd/net10-migration/03-src-dependencies.md` - - `docs/sdd/net10-migration/04-validation-pack-ci.md` -- Branch compartilhada registrada: `chore/net10-migration`. -- Branch atual: `chore/net10-migration`. -- SDK local: `10.0.302`. -- Nao ha `global.json`, `Directory.Build.props` ou `Directory.Packages.props`. -- Runner de testes: VSTest por `Microsoft.NET.Test.Sdk` e `xunit.runner.visualstudio`; Microsoft Testing Platform nao foi adotado como runner do `dotnet test`. - -Cadeia transitiva antes da correcao: - -```text -Dapper.FluentMap.Tests [net10.0] -└── Microsoft.Data.Sqlite 10.0.10 - └── SQLitePCLRaw.bundle_e_sqlite3 2.1.11 - └── SQLitePCLRaw.lib.e_sqlite3 2.1.11 -``` - -Baseline NuGet antes da correcao: - -- `dotnet nuget why test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj SQLitePCLRaw.lib.e_sqlite3` confirmou a cadeia acima. -- `dotnet package list --project test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj --include-transitive` confirmou: - - `SQLitePCLRaw.bundle_e_sqlite3 2.1.11` - - `SQLitePCLRaw.core 2.1.11` - - `SQLitePCLRaw.lib.e_sqlite3 2.1.11` - - `SQLitePCLRaw.provider.e_sqlite3 2.1.11` -- `dotnet package list --project test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj --include-transitive --vulnerable` reportou somente: - - `SQLitePCLRaw.lib.e_sqlite3 2.1.11`, severidade High, `GHSA-2m69-gcr7-jv3q`. -- `Dapper.FluentMap.Dommel.Tests` nao tem dependencia de `SQLitePCLRaw.lib.e_sqlite3` e nao reportou pacotes vulneraveis. - -Metadata oficial consultada: - -- NuGet.org mostra `Microsoft.Data.Sqlite 10.0.10` como versao estavel atual e dependente de `SQLitePCLRaw.bundle_e_sqlite3 >= 2.1.11` e `SQLitePCLRaw.core >= 2.1.11`. -- NuGet.org mostra `SQLitePCLRaw.bundle_e_sqlite3` com versoes estaveis `2.1.12` e `3.0.4`; a linha `2.1.12` preserva compatibilidade declarada com `.NETStandard 2.0`, `.NETFramework 4.6.1` e TFMs modernos computados, incluindo `net10.0`. -- NuGet.org mostra `SQLitePCLRaw.lib.e_sqlite3 2.1.12` como versao estavel da mesma linha 2.1 e sem marcacao de vulnerabilidade na pagina do pacote, enquanto `2.1.11` aparece como vulneravel. -- NuGet.org mostra `SQLitePCLRaw.lib.e_sqlite3 3.53.3` como versao estavel mais recente do pacote nativo, mas essa versao pertence a outra linha maior/familia de dependencias. -- GitHub Advisory `GHSA-2m69-gcr7-jv3q` / `CVE-2025-6965` afeta `SQLitePCLRaw.lib.e_sqlite3 <= 2.1.11`, com severidade High, por embutir SQLite anterior a `3.50.2`. - -## Decision - -Causa raiz: - -- Pacote pai direto: `Microsoft.Data.Sqlite 10.0.10`, usado somente por `test/Dapper.FluentMap.Tests`. -- Caminho transitivo: `Microsoft.Data.Sqlite` -> `SQLitePCLRaw.bundle_e_sqlite3` -> `SQLitePCLRaw.lib.e_sqlite3`. -- Versao vulneravel: `SQLitePCLRaw.lib.e_sqlite3 2.1.11`. -- Advisory: `GHSA-2m69-gcr7-jv3q` / `CVE-2025-6965`, severidade High. -- Projeto afetado: `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj`. -- Projeto nao afetado: `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj`. - -Estrategia escolhida: Opcao B, adicionar referencia direta test-only a `SQLitePCLRaw.bundle_e_sqlite3 2.1.12` com `PrivateAssets="all"` no projeto `Dapper.FluentMap.Tests`. - -Motivos: - -- `Microsoft.Data.Sqlite 10.0.10` ja e a versao estavel atual e ainda declara piso transitivo `SQLitePCLRaw.bundle_e_sqlite3 >= 2.1.11`; atualizar o pacote pai nao remove a versao vulneravel. -- Pin do bundle mantem a familia SQLitePCLRaw coerente, atualizando junto `core`, `provider` e `lib` para a linha `2.1.12`. -- `2.1.12` e a menor atualizacao estavel dentro da mesma major/linha que evita `2.1.11`; evita migrar para a familia `3.x` sem necessidade. -- A referencia e exclusivamente para controlar uma dependencia transitiva de testes, portanto `PrivateAssets="all"` e apropriado. -- A mudanca nao adiciona SQLite a projetos em `src/` e nao altera o pacote NuGet de producao. - -Alternativas descartadas: - -- Opcao A, atualizar `Microsoft.Data.Sqlite`: descartada porque `10.0.10` ja e a versao estavel atual e ainda permite o piso vulneravel `2.1.11`. -- Pin direto de `SQLitePCLRaw.lib.e_sqlite3`: descartado porque o bundle e o ponto de composicao usado pelo pacote pai e mantem os pacotes SQLitePCLRaw alinhados. -- Atualizar para `SQLitePCLRaw.bundle_e_sqlite3 3.0.4`: descartado por ser mudanca de major/familia desnecessaria para eliminar a vulnerabilidade test-only. - -## Delivery - -- Atualizado `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` com referencia direta: - -```xml - -``` - -- Nenhum projeto em `src/` recebeu dependencia SQLite. -- `test/Dapper.FluentMap.Dommel.Tests` nao foi alterado porque nao possui a cadeia SQLitePCLRaw. -- Nenhum codigo C# de producao ou teste foi alterado. -- Nenhum teste foi enfraquecido, removido ou ignorado. -- Nenhuma dependencia funcional nao relacionada foi atualizada. - -Versoes finais resolvidas em `Dapper.FluentMap.Tests`: - -```text -SQLitePCLRaw.bundle_e_sqlite3 2.1.12 -SQLitePCLRaw.core 2.1.12 -SQLitePCLRaw.lib.e_sqlite3 2.1.12 -SQLitePCLRaw.provider.e_sqlite3 2.1.12 -``` - -## Validation - -Comandos executados: - -| Comando | Resultado | -|---|---| -| `dotnet restore` | Aprovado, sem NU1903 para `SQLitePCLRaw.lib.e_sqlite3`. | -| `dotnet nuget why test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj SQLitePCLRaw.lib.e_sqlite3` | Aprovado; cadeia final resolve `SQLitePCLRaw.lib.e_sqlite3 2.1.12` via `Microsoft.Data.Sqlite 10.0.10` e via pin direto do bundle. | -| `dotnet package list --project test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj --include-transitive` | Aprovado; `SQLitePCLRaw.lib.e_sqlite3 2.1.11` nao aparece; `2.1.12` aparece. | -| `dotnet package list --project test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj --include-transitive --vulnerable` | Aprovado; nenhum pacote vulneravel reportado. | -| `dotnet package list --project test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj --include-transitive --vulnerable` | Aprovado; nenhum pacote vulneravel reportado. | -| `dotnet package list --project src/Dapper.FluentMap/Dapper.FluentMap.csproj --include-transitive --vulnerable` | Aprovado; nenhum pacote vulneravel em `src/Dapper.FluentMap`. | -| `dotnet package list --project src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj --include-transitive --vulnerable` | Aprovado; nenhum pacote vulneravel em `src/Dapper.FluentMap.Dommel`. | -| `dotnet build --configuration Release` | Aprovado; 0 avisos, 0 erros. | -| `dotnet test test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj --configuration Release --no-build --filter FullyQualifiedName~DapperIntegrationTests` | Aprovado; 7 testes SQLite/Dapper, 0 falhas, 0 ignorados. | -| `dotnet test test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj --configuration Release --no-build` | Aprovado; 45 testes, 0 falhas, 0 ignorados. | -| `dotnet test --configuration Release --no-build` | Aprovado; `Dapper.FluentMap.Tests` 45/45 e `Dapper.FluentMap.Dommel.Tests` 7/7. | -| `dotnet pack src/Dapper.FluentMap/Dapper.FluentMap.csproj --configuration Release --no-build` | Aprovado; gerou `Dapper.FluentMap.2.0.0.nupkg`. Avisos existentes: NU5125 para `licenseUrl` e recomendacao de README de pacote. | - -Inspecao do pacote: - -- Conteudo do pacote de producao: - - `lib/netstandard2.0/Dapper.FluentMap.dll` - - `lib/netstandard2.0/Dapper.FluentMap.xml` - - `Dapper.FluentMap.nuspec` - - metadados padrao do pacote -- Dependencia unica na `.nuspec`: - - `.NETStandard2.0`: `Dapper 2.1.79` -- Confirmado que a `.nuspec` nao contem: - - `Microsoft.Data.Sqlite` - - `SQLitePCLRaw.*` - -Confirmacoes finais: - -- Advisory eliminado do audit NuGet: `GHSA-2m69-gcr7-jv3q` / `CVE-2025-6965`. -- `SQLitePCLRaw.lib.e_sqlite3 2.1.11` nao aparece mais no grafo restaurado do projeto afetado. -- Dependencia SQLite permanece test-only. -- Projetos em `src/` nao receberam dependencia SQLite. -- Nao houve breaking change de API publica. -- Nao houve push, pull request, publicacao NuGet, tag ou release.